From 170fd3ee0f434197f69fab563c08a58e9aa805c0 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 23 Mar 2026 21:45:36 +0800 Subject: [PATCH] Rebuild desktop persistence as file stores --- analysis_options.yaml | 1 + docs/howto/persistence-storage-layout.md | 117 +++++ lib/runtime/file_store_support.dart | 325 +++++++++++++ lib/runtime/secret_store.dart | 432 ++++++++++++++---- lib/runtime/secure_config_store.dart | 14 +- lib/runtime/settings_store.dart | 274 +++++++++-- linux/flutter/generated_plugin_registrant.cc | 8 - linux/flutter/generated_plugins.cmake | 2 - macos/Flutter/GeneratedPluginRegistrant.swift | 4 - pubspec.lock | 71 --- pubspec.yaml | 7 - test/runtime/secure_config_store_suite.dart | 179 +++----- .../flutter/generated_plugin_registrant.cc | 6 - windows/flutter/generated_plugins.cmake | 2 - 14 files changed, 1100 insertions(+), 342 deletions(-) create mode 100644 docs/howto/persistence-storage-layout.md create mode 100644 lib/runtime/file_store_support.dart diff --git a/analysis_options.yaml b/analysis_options.yaml index b676128d..e8067c19 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -14,6 +14,7 @@ analyzer: - third_party/**/example/** - third_party/**/test/** - third_party/**/analysis_options.yaml + - third_party/flutter_secure_storage_windows/** linter: # The lint rules applied to this project can be customized in the diff --git a/docs/howto/persistence-storage-layout.md b/docs/howto/persistence-storage-layout.md new file mode 100644 index 00000000..2bcb4cc3 --- /dev/null +++ b/docs/howto/persistence-storage-layout.md @@ -0,0 +1,117 @@ +# Persistence Storage Layout + +## 目标 + +本文定义桌面端持久化层的唯一落地规则。`XWorkmate.svc.plus` 后续必须只认这一套目录和文件约定,不再引入 SQLite、本地 secret fallback、或第二套临时持久化路径。 + +## 存储原则 + +- 非敏感配置只写 `settings.yaml` +- 任务线程会话按 `sessionKey` 单文件保存 +- 敏感信息只写固定 `secret path` +- 首次启动必须自动建目录 +- 重启和升级不能主动删除配置或会话文件 +- 文件写入使用临时文件后替换的原子写策略 +- 磁盘路径不可用时,只允许退回内存,不再切换到另一套本地 fallback 持久化 + +## 默认目录结构 + +默认根目录位于应用支持目录下的 `xworkmate` 子目录。 + +macOS 示例: + +```text +~/Library/Application Support//xworkmate/ +``` + +运行时布局: + +```text +xworkmate/ + config/ + settings.yaml + secret-audit.json + tasks/ + index.json + .json + secrets/ + .secret +``` + +## 文件职责 + +### `config/settings.yaml` + +- 唯一非敏感配置源 +- 内容对应 `SettingsSnapshot.toJson()` +- 不保存 token、password、API key、device private key 等敏感字段 + +### `config/secret-audit.json` + +- 保存 `SecretAuditEntry` 列表 +- 属于本地非敏感审计信息 +- 最大长度由运行时控制 + +### `tasks/index.json` + +- 保存线程会话顺序 +- 当前格式: + +```json +{ + "version": 1, + "sessions": ["session-a", "session-b"] +} +``` + +### `tasks/.json` + +- 每个线程会话一个文件 +- 文件内容为 `AssistantThreadRecord.toJson()` +- 文件名不直接使用原始 `sessionKey`,而是稳定编码后的结果,避免跨平台文件名问题 +- 记录内容里的 `sessionKey` 仍保持原值,不修改模型 + +### `secrets/.secret` + +- 固定 secret path +- 每个 secret key 一个文件 +- 保存 Gateway token、Gateway password、AI Gateway API key、Vault token、device identity、device token 等敏感信息 +- 文件名使用稳定编码,避免泄露原始 key 名并规避非法字符 + +## 初始化规则 + +- `SecureConfigStore.initialize()` 必须先准备目录结构 +- 不要求用户先保存一次配置,目录应在首次运行时就存在 +- 如果外部显式传入测试路径覆盖,仍然遵守相同布局 + +## 清理规则 + +- `clearAssistantLocalState()` 只清理: + - `settings.yaml` + - `tasks/index.json` + - `tasks/*.json` +- 不清理 `secrets/*.secret` +- 不主动清理 `secret-audit.json` + +## 恢复规则 + +- 启动时先读 `settings.yaml` +- 再读 `tasks/index.json` 与对应 task 文件 +- `index.json` 缺失时,允许扫描 `tasks/*.json` 进行恢复 +- `secret path` 中某个 key 缺失时,只影响该 key,不应拖垮整个 store + +## 禁止事项 + +- 禁止重新引入 SQLite 作为桌面持久化主存储 +- 禁止把 secret 写入 `SharedPreferences` +- 禁止把 `.env` 自动导入为持久化配置 +- 禁止在 secret path 不可用时偷偷切换到另一套磁盘 fallback 路径 +- 禁止在升级或启动时主动删除已有配置与会话文件 + +## 测试建议 + +- 验证首次启动自动建目录 +- 验证重启后 `settings.yaml` 可恢复 +- 验证 `tasks/.json` 跨实例可恢复 +- 验证 `clearAssistantLocalState()` 不删 secrets +- 验证磁盘不可用时保留内存态,不发生崩溃 diff --git a/lib/runtime/file_store_support.dart b/lib/runtime/file_store_support.dart new file mode 100644 index 00000000..2a663ce0 --- /dev/null +++ b/lib/runtime/file_store_support.dart @@ -0,0 +1,325 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path_provider/path_provider.dart'; +import 'package:yaml/yaml.dart'; + +class StoreLayout { + const StoreLayout({ + required this.rootDirectory, + required this.configDirectory, + required this.tasksDirectory, + required this.secretDirectory, + }); + + final Directory rootDirectory; + final Directory configDirectory; + final Directory tasksDirectory; + final Directory secretDirectory; + + File get settingsFile => File('${configDirectory.path}/settings.yaml'); + + File get auditFile => File('${configDirectory.path}/secret-audit.json'); + + File get taskIndexFile => File('${tasksDirectory.path}/index.json'); + + File taskFileForSessionKey(String sessionKey) { + final encoded = encodeStableFileKey(sessionKey); + return File('${tasksDirectory.path}/$encoded.json'); + } + + File secretFileForKey(String key) { + final encoded = encodeStableFileKey(key); + return File('${secretDirectory.path}/$encoded.secret'); + } +} + +class StoreLayoutResolver { + StoreLayoutResolver({ + Future Function()? localRootPathResolver, + Future Function()? secretRootPathResolver, + Future Function()? supportRootPathResolver, + }) : _localRootPathResolver = localRootPathResolver, + _secretRootPathResolver = secretRootPathResolver, + _supportRootPathResolver = supportRootPathResolver; + + final Future Function()? _localRootPathResolver; + final Future Function()? _secretRootPathResolver; + final Future Function()? _supportRootPathResolver; + + StoreLayout? _cached; + + Future resolve() async { + final cached = _cached; + if (cached != null) { + return cached; + } + final supportRootPath = + await _resolvePath(_supportRootPathResolver) ?? + await _defaultSupportRootPath(); + if (supportRootPath == null) { + throw StateError('Cannot resolve persistent storage root.'); + } + final localRootPath = + await _resolvePath(_localRootPathResolver) ?? supportRootPath; + final secretRootPath = + await _resolvePath(_secretRootPathResolver) ?? + '$supportRootPath/secrets'; + final rootDirectory = await ensureDirectory( + normalizeStoreDirectoryPath(localRootPath), + ); + final configDirectory = await ensureDirectory( + '${rootDirectory.path}/config', + ); + final tasksDirectory = await ensureDirectory('${rootDirectory.path}/tasks'); + final secretDirectory = await ensureDirectory( + normalizeStoreDirectoryPath(secretRootPath), + ); + final layout = StoreLayout( + rootDirectory: rootDirectory, + configDirectory: configDirectory, + tasksDirectory: tasksDirectory, + secretDirectory: secretDirectory, + ); + _cached = layout; + return layout; + } + + Future _defaultSupportRootPath() async { + try { + final supportDirectory = await getApplicationSupportDirectory(); + return '${supportDirectory.path}/xworkmate'; + } catch (_) { + final home = Platform.environment['HOME']?.trim() ?? ''; + if (home.isEmpty) { + return null; + } + if (Platform.isMacOS) { + return '$home/Library/Application Support/xworkmate'; + } + if (Platform.isLinux) { + final xdgConfigHome = + Platform.environment['XDG_CONFIG_HOME']?.trim() ?? ''; + if (xdgConfigHome.isNotEmpty) { + return '$xdgConfigHome/xworkmate'; + } + return '$home/.config/xworkmate'; + } + if (Platform.isWindows) { + final appData = Platform.environment['APPDATA']?.trim() ?? ''; + if (appData.isNotEmpty) { + return '$appData\\xworkmate'; + } + } + return '$home/.xworkmate'; + } + } + + Future _resolvePath(Future Function()? resolver) async { + if (resolver == null) { + return null; + } + try { + final value = await resolver(); + final trimmed = value?.trim() ?? ''; + if (trimmed.isEmpty) { + return null; + } + return normalizeStoreDirectoryPath(trimmed); + } catch (_) { + return null; + } + } +} + +String normalizeStoreDirectoryPath(String path) { + final trimmed = path.trim(); + if (trimmed.isEmpty) { + return trimmed; + } + final lower = trimmed.toLowerCase(); + if (lower.endsWith('.sqlite') || + lower.endsWith('.sqlite3') || + lower.endsWith('.db') || + lower.endsWith('.yaml') || + lower.endsWith('.yml') || + lower.endsWith('.json')) { + return File(trimmed).parent.path; + } + return trimmed; +} + +Future ensureDirectory(String path) async { + final directory = Directory(path); + if (!await directory.exists()) { + await directory.create(recursive: true); + } + return directory; +} + +String encodeStableFileKey(String key) { + return base64Url.encode(utf8.encode(key)).replaceAll('=', ''); +} + +Future atomicWriteString(File file, String contents) async { + if (!await file.parent.exists()) { + await file.parent.create(recursive: true); + } + final tempFile = File( + '${file.path}.tmp-${DateTime.now().microsecondsSinceEpoch}', + ); + await tempFile.writeAsString(contents, flush: true); + await tempFile.rename(file.path); +} + +Future deleteIfExists(File file) async { + if (await file.exists()) { + await file.delete(); + } +} + +Object? decodeYamlDocument(String raw) { + final trimmed = raw.trim(); + if (trimmed.isEmpty) { + return null; + } + try { + return _yamlToObject(loadYaml(trimmed)); + } catch (_) { + return null; + } +} + +Object? _yamlToObject(Object? value) { + if (value is YamlMap) { + return value.map( + (Object? key, Object? item) => + MapEntry(key?.toString() ?? '', _yamlToObject(item)), + ); + } + if (value is YamlList) { + return value.map(_yamlToObject).toList(growable: false); + } + return value; +} + +String encodeYamlDocument(Object? value) { + final buffer = StringBuffer('---\n'); + _writeYamlValue(buffer, value, 0, listItem: false); + if (!buffer.toString().endsWith('\n')) { + buffer.writeln(); + } + return buffer.toString(); +} + +void _writeYamlValue( + StringBuffer buffer, + Object? value, + int indent, { + required bool listItem, +}) { + final prefix = ' ' * indent; + if (value is Map) { + if (value.isEmpty) { + if (listItem) { + buffer.writeln('{}'); + } else { + buffer.writeln('$prefix{}'); + } + return; + } + if (listItem) { + buffer.writeln(); + } + for (final entry in value.entries) { + final key = entry.key.toString(); + final item = entry.value; + if (_isInlineYamlValue(item)) { + buffer.writeln('$prefix$key: ${_yamlInlineValue(item)}'); + } else if (item is String && item.contains('\n')) { + buffer.writeln('$prefix$key: |-'); + for (final line in item.split('\n')) { + buffer.writeln('${' ' * (indent + 1)}$line'); + } + } else { + buffer.writeln('$prefix$key:'); + _writeYamlValue(buffer, item, indent + 1, listItem: false); + } + } + return; + } + if (value is List) { + if (value.isEmpty) { + if (listItem) { + buffer.writeln('[]'); + } else { + buffer.writeln('$prefix[]'); + } + return; + } + if (listItem) { + buffer.writeln(); + } + for (final item in value) { + if (_isInlineYamlValue(item)) { + buffer.writeln('$prefix- ${_yamlInlineValue(item)}'); + } else if (item is String && item.contains('\n')) { + buffer.writeln('$prefix- |-'); + for (final line in item.split('\n')) { + buffer.writeln('${' ' * (indent + 1)}$line'); + } + } else { + buffer.writeln('$prefix-'); + _writeYamlValue(buffer, item, indent + 1, listItem: false); + } + } + return; + } + if (listItem) { + buffer.writeln(_yamlInlineValue(value)); + return; + } + buffer.writeln('$prefix${_yamlInlineValue(value)}'); +} + +bool _isInlineYamlValue(Object? value) { + if (value == null || value is bool || value is num) { + return true; + } + if (value is String) { + return !value.contains('\n'); + } + if (value is List) { + return value.isEmpty; + } + if (value is Map) { + return value.isEmpty; + } + return false; +} + +String _yamlInlineValue(Object? value) { + if (value == null) { + return 'null'; + } + if (value is bool || value is num) { + return value.toString(); + } + if (value is List && value.isEmpty) { + return '[]'; + } + if (value is Map && value.isEmpty) { + return '{}'; + } + final stringValue = value.toString(); + if (stringValue.isEmpty) { + return "''"; + } + final safe = RegExp(r'^[A-Za-z0-9_./:@+%-]+$'); + final reserved = {'null', 'true', 'false', '~'}; + if (safe.hasMatch(stringValue) && !reserved.contains(stringValue)) { + return stringValue; + } + final escaped = stringValue.replaceAll("'", "''"); + return "'$escaped'"; +} diff --git a/lib/runtime/secret_store.dart b/lib/runtime/secret_store.dart index 1e459d70..64663c04 100644 --- a/lib/runtime/secret_store.dart +++ b/lib/runtime/secret_store.dart @@ -1,3 +1,7 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'file_store_support.dart'; import 'runtime_models.dart'; abstract class SecureStorageClient { @@ -8,6 +12,50 @@ abstract class SecureStorageClient { Future delete({required String key}); } +class FileSecureStorageClient implements SecureStorageClient { + FileSecureStorageClient(this._directoryResolver); + + final Future Function() _directoryResolver; + + @override + Future delete({required String key}) async { + final file = await _fileForKey(key); + if (file != null && await file.exists()) { + await file.delete(); + } + } + + @override + Future 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 write({required String key, required String value}) async { + final file = await _fileForKey(key); + if (file == null) { + throw StateError('Secret directory unavailable for $key'); + } + await atomicWriteString(file, '$value\n'); + } + + Future _fileForKey(String key) async { + final directory = await _directoryResolver(); + if (directory == null) { + return null; + } + if (!await directory.exists()) { + await directory.create(recursive: true); + } + return File('${directory.path}/${encodeStableFileKey(key)}.secret'); + } +} + class SecretStore { SecretStore({ Future Function()? fallbackDirectoryPathResolver, @@ -15,94 +63,197 @@ class SecretStore { Future Function()? defaultSupportDirectoryPathResolver, SecureStorageClient? secureStorage, bool enableSecureStorage = true, - }); + StoreLayoutResolver? layoutResolver, + }) : _layoutResolver = + layoutResolver ?? + StoreLayoutResolver( + localRootPathResolver: databasePathResolver, + secretRootPathResolver: fallbackDirectoryPathResolver, + supportRootPathResolver: defaultSupportDirectoryPathResolver, + ), + _secureStorageOverride = secureStorage; static const String legacyLocalStateKey = 'xworkmate.local_state.key'; - Future initialize() async {} + 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 _gatewayDeviceCreatedAtKey = + 'xworkmate.gateway.device.created_at_ms'; + 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'; - Future loadGatewayToken({int? profileIndex}) { - throw StateError( - 'Legacy secret persistence removed. New secret-path store is pending implementation.', - ); + final StoreLayoutResolver _layoutResolver; + final SecureStorageClient? _secureStorageOverride; + final Map _memorySecure = {}; + StoreLayout? _layout; + SecureStorageClient? _secureStorage; + bool _initialized = false; + + Future initialize() async { + if (_initialized) { + return; + } + _initialized = true; + if (_secureStorageOverride != null) { + _secureStorage = _secureStorageOverride; + return; + } + try { + _layout = await _layoutResolver.resolve(); + _secureStorage = FileSecureStorageClient( + () async => _layout?.secretDirectory, + ); + } catch (_) { + _layout = null; + _secureStorage = null; + } } - Future saveGatewayToken(String value, {int? profileIndex}) { - throw StateError( - 'Legacy secret persistence removed. New secret-path store is pending implementation.', - ); + Future 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 clearGatewayToken({int? profileIndex}) { - throw StateError( - 'Legacy secret persistence removed. New secret-path store is pending implementation.', - ); + Future saveGatewayToken(String value, {int? profileIndex}) => + _writeSecure( + profileIndex == null + ? _legacyGatewayTokenKey + : _gatewayTokenKeyForProfile(profileIndex), + value, + ); + + Future clearGatewayToken({int? profileIndex}) => _deleteSecure( + profileIndex == null + ? _legacyGatewayTokenKey + : _gatewayTokenKeyForProfile(profileIndex), + ); + + Future 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 loadGatewayPassword({int? profileIndex}) { - throw StateError( - 'Legacy secret persistence removed. New secret-path store is pending implementation.', - ); - } + Future saveGatewayPassword(String value, {int? profileIndex}) => + _writeSecure( + profileIndex == null + ? _legacyGatewayPasswordKey + : _gatewayPasswordKeyForProfile(profileIndex), + value, + ); - Future saveGatewayPassword(String value, {int? profileIndex}) { - throw StateError( - 'Legacy secret persistence removed. New secret-path store is pending implementation.', - ); - } + Future clearGatewayPassword({int? profileIndex}) => _deleteSecure( + profileIndex == null + ? _legacyGatewayPasswordKey + : _gatewayPasswordKeyForProfile(profileIndex), + ); - Future clearGatewayPassword({int? profileIndex}) { - throw StateError( - 'Legacy secret persistence removed. New secret-path store is pending implementation.', - ); - } + Future loadOllamaCloudApiKey() => _readSecure(_ollamaCloudApiKeyKey); - Future loadOllamaCloudApiKey() { - throw StateError( - 'Legacy secret persistence removed. New secret-path store is pending implementation.', - ); - } + Future saveOllamaCloudApiKey(String value) => + _writeSecure(_ollamaCloudApiKeyKey, value); - Future saveOllamaCloudApiKey(String value) { - throw StateError( - 'Legacy secret persistence removed. New secret-path store is pending implementation.', - ); - } + Future loadVaultToken() => _readSecure(_vaultTokenKey); - Future loadVaultToken() { - throw StateError( - 'Legacy secret persistence removed. New secret-path store is pending implementation.', - ); - } + Future saveVaultToken(String value) => + _writeSecure(_vaultTokenKey, value); - Future saveVaultToken(String value) { - throw StateError( - 'Legacy secret persistence removed. New secret-path store is pending implementation.', - ); - } + Future loadAiGatewayApiKey() => _readSecure(_aiGatewayApiKeyKey); - Future loadAiGatewayApiKey() { - throw StateError( - 'Legacy secret persistence removed. New secret-path store is pending implementation.', - ); - } + Future saveAiGatewayApiKey(String value) => + _writeSecure(_aiGatewayApiKeyKey, value); - Future saveAiGatewayApiKey(String value) { - throw StateError( - 'Legacy secret persistence removed. New secret-path store is pending implementation.', - ); - } + Future clearAiGatewayApiKey() => _deleteSecure(_aiGatewayApiKeyKey); - Future clearAiGatewayApiKey() { - throw StateError( - 'Legacy secret persistence removed. New secret-path store is pending implementation.', - ); - } - - Future> loadSecureRefs() { - throw StateError( - 'Legacy secret persistence removed. New secret-path store is pending implementation.', - ); + Future> loadSecureRefs() async { + await initialize(); + final secureRefs = {}; + final legacyGatewayToken = await _readSecure(_legacyGatewayTokenKey); + final legacyGatewayPassword = await _readSecure(_legacyGatewayPasswordKey); + 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; + } + } + final deviceIdentity = await loadDeviceIdentity(); + if (deviceIdentity != null) { + final deviceToken = await loadDeviceToken( + deviceId: deviceIdentity.deviceId, + role: 'operator', + ); + if (deviceToken case final value?) { + secureRefs['gateway_device_token_operator'] = value; + } + } + final ollamaKey = await loadOllamaCloudApiKey(); + final vaultToken = await loadVaultToken(); + final aiGatewayApiKey = await loadAiGatewayApiKey(); + 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; } static String gatewayTokenRefKey(int profileIndex) => @@ -111,54 +262,70 @@ class SecretStore { static String gatewayPasswordRefKey(int profileIndex) => _gatewayPasswordRefKey(profileIndex); - Future loadDeviceIdentity() { - throw StateError( - 'Legacy secret persistence removed. New secret-path store is pending implementation.', + Future 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; + } + final createdAtMs = + int.tryParse(await _readSecure(_gatewayDeviceCreatedAtKey) ?? '') ?? 0; + return LocalDeviceIdentity( + deviceId: deviceId, + publicKeyBase64Url: publicKey, + privateKeyBase64Url: privateKey, + createdAtMs: createdAtMs, ); } - Future saveDeviceIdentity(LocalDeviceIdentity identity) { - throw StateError( - 'Legacy secret persistence removed. New secret-path store is pending implementation.', + Future saveDeviceIdentity(LocalDeviceIdentity identity) async { + await initialize(); + await _writeSecure(_gatewayDeviceIdKey, identity.deviceId); + await _writeSecure(_gatewayDevicePublicKeyKey, identity.publicKeyBase64Url); + await _writeSecure( + _gatewayDevicePrivateKeyKey, + identity.privateKeyBase64Url, + ); + await _writeSecure( + _gatewayDeviceCreatedAtKey, + identity.createdAtMs.toString(), ); } Future loadDeviceToken({ required String deviceId, required String role, - }) { - throw StateError( - 'Legacy secret persistence removed. New secret-path store is pending implementation.', - ); - } + }) => _readSecure(_deviceTokenKey(deviceId, role)); Future saveDeviceToken({ required String deviceId, required String role, required String token, - }) { - throw StateError( - 'Legacy secret persistence removed. New secret-path store is pending implementation.', - ); - } + }) => _writeSecure(_deviceTokenKey(deviceId, role), token); Future clearDeviceToken({ required String deviceId, required String role, - }) { - throw StateError( - 'Legacy secret persistence removed. New secret-path store is pending implementation.', - ); + }) => _deleteSecure(_deviceTokenKey(deviceId, role)); + + Future?> loadLegacyLocalStateKeyBytes() async { + final encoded = await _readSecure(legacyLocalStateKey); + final trimmed = encoded?.trim() ?? ''; + if (trimmed.isEmpty) { + return null; + } + return _base64UrlDecode(trimmed); } - Future?> loadLegacyLocalStateKeyBytes() { - throw StateError( - 'Legacy secret persistence removed. New secret-path store is pending implementation.', - ); + Future dispose() async { + _memorySecure.clear(); + _secureStorage = null; + _layout = null; + _initialized = false; } - Future dispose() async {} - static String maskValue(String value) { final trimmed = value.trim(); if (trimmed.isEmpty) { @@ -175,4 +342,79 @@ class SecretStore { static String _gatewayPasswordRefKey(int profileIndex) => 'gateway_password_$profileIndex'; + + static const List _gatewayProfileFallbackOrder = [ + kGatewayRemoteProfileIndex, + kGatewayLocalProfileIndex, + 2, + 3, + 4, + ]; + + Future _readSecure(String key) async { + await initialize(); + final client = _secureStorage; + if (client != null) { + try { + final value = (await client.read(key: key))?.trim(); + if (value != null && value.isNotEmpty) { + _memorySecure[key] = value; + return value; + } + } catch (_) { + // Fall back to memory only when the secret path is unavailable. + } + } + final memoryValue = _memorySecure[key]?.trim() ?? ''; + return memoryValue.isEmpty ? null : memoryValue; + } + + Future _writeSecure(String key, String value) async { + await initialize(); + final trimmed = value.trim(); + if (trimmed.isEmpty) { + return; + } + _memorySecure[key] = trimmed; + final client = _secureStorage; + if (client == null) { + return; + } + try { + await client.write(key: key, value: trimmed); + } catch (_) { + // Memory remains authoritative until the next successful durable write. + } + } + + Future _deleteSecure(String key) async { + await initialize(); + _memorySecure.remove(key); + final client = _secureStorage; + if (client == null) { + return; + } + try { + await client.delete(key: key); + } catch (_) { + // Ignore durable delete failures and keep the memory state cleared. + } + } + + 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 List _base64UrlDecode(String value) { + final normalized = value.replaceAll('-', '+').replaceAll('_', '/'); + final padded = normalized + '=' * ((4 - normalized.length % 4) % 4); + return base64.decode(padded); + } } diff --git a/lib/runtime/secure_config_store.dart b/lib/runtime/secure_config_store.dart index 32bc8d2e..25d92c34 100644 --- a/lib/runtime/secure_config_store.dart +++ b/lib/runtime/secure_config_store.dart @@ -1,6 +1,7 @@ export 'secret_store.dart'; export 'settings_store.dart'; +import 'file_store_support.dart'; import 'runtime_models.dart'; import 'secret_store.dart'; import 'settings_store.dart'; @@ -14,20 +15,25 @@ class SecureConfigStore { SecureStorageClient? secureStorage, bool enableSecureStorage = true, }) { + final layoutResolver = StoreLayoutResolver( + localRootPathResolver: databasePathResolver, + secretRootPathResolver: fallbackDirectoryPathResolver, + supportRootPathResolver: defaultSupportDirectoryPathResolver, + ); _secretStore = SecretStore( fallbackDirectoryPathResolver: fallbackDirectoryPathResolver, databasePathResolver: databasePathResolver, - defaultSupportDirectoryPathResolver: - defaultSupportDirectoryPathResolver, + defaultSupportDirectoryPathResolver: defaultSupportDirectoryPathResolver, secureStorage: secureStorage, enableSecureStorage: enableSecureStorage, + layoutResolver: layoutResolver, ); _settingsStore = SettingsStore( fallbackDirectoryPathResolver: fallbackDirectoryPathResolver, databasePathResolver: databasePathResolver, - defaultSupportDirectoryPathResolver: - defaultSupportDirectoryPathResolver, + defaultSupportDirectoryPathResolver: defaultSupportDirectoryPathResolver, databaseOpener: databaseOpener, + layoutResolver: layoutResolver, ); } diff --git a/lib/runtime/settings_store.dart b/lib/runtime/settings_store.dart index cab0d27e..41d7bd51 100644 --- a/lib/runtime/settings_store.dart +++ b/lib/runtime/settings_store.dart @@ -1,10 +1,12 @@ import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'file_store_support.dart'; import 'runtime_models.dart'; -typedef SecureConfigDatabaseOpener = FutureOr Function( - String resolvedPath, -); +typedef SecureConfigDatabaseOpener = + FutureOr Function(String resolvedPath); class SettingsStore { SettingsStore({ @@ -12,7 +14,13 @@ class SettingsStore { Future Function()? databasePathResolver, Future Function()? defaultSupportDirectoryPathResolver, SecureConfigDatabaseOpener? databaseOpener, - }); + StoreLayoutResolver? layoutResolver, + }) : _layoutResolver = + layoutResolver ?? + StoreLayoutResolver( + localRootPathResolver: databasePathResolver, + supportRootPathResolver: defaultSupportDirectoryPathResolver, + ); static const String settingsKey = 'xworkmate.settings.snapshot'; static const String auditKey = 'xworkmate.secrets.audit'; @@ -20,51 +28,251 @@ class SettingsStore { static const String databaseFileName = 'config-store.sqlite3'; static const String databaseTableName = 'config_entries'; - Future initialize() async {} + final StoreLayoutResolver _layoutResolver; + bool _initialized = false; + StoreLayout? _layout; + SettingsSnapshot _settingsSnapshot = SettingsSnapshot.defaults(); + List _threadRecords = const []; + List _auditTrail = const []; - Future loadSettingsSnapshot() { - throw StateError( - 'Legacy settings persistence removed. New file-based settings store is pending implementation.', - ); + Future initialize() async { + if (_initialized) { + return; + } + _initialized = true; + try { + _layout = await _layoutResolver.resolve(); + } catch (_) { + _layout = null; + return; + } + _settingsSnapshot = await _readSettingsSnapshot(); + _threadRecords = await _readAssistantThreadRecords(); + _auditTrail = await _readAuditTrail(); } - Future saveSettingsSnapshot(SettingsSnapshot snapshot) { - throw StateError( - 'Legacy settings persistence removed. New file-based settings store is pending implementation.', - ); + Future loadSettingsSnapshot() async { + await initialize(); + return _settingsSnapshot; } - Future> loadAssistantThreadRecords() { - throw StateError( - 'Legacy settings persistence removed. New file-based settings store is pending implementation.', - ); + Future saveSettingsSnapshot(SettingsSnapshot snapshot) async { + await initialize(); + _settingsSnapshot = snapshot; + final layout = _layout; + if (layout == null) { + return; + } + try { + await atomicWriteString( + layout.settingsFile, + encodeYamlDocument(snapshot.toJson()), + ); + } catch (_) { + // Preserve the in-memory snapshot when the persistent write fails. + } + } + + Future> loadAssistantThreadRecords() async { + await initialize(); + return List.from(_threadRecords); } Future saveAssistantThreadRecords( List records, - ) { - throw StateError( - 'Legacy settings persistence removed. New file-based settings store is pending implementation.', - ); + ) async { + await initialize(); + final normalized = records + .where((item) => item.sessionKey.trim().isNotEmpty) + .toList(growable: false); + _threadRecords = normalized; + final layout = _layout; + if (layout == null) { + return; + } + final keptPaths = {}; + try { + for (final record in normalized) { + final taskFile = layout.taskFileForSessionKey(record.sessionKey); + keptPaths.add(taskFile.path); + await atomicWriteString(taskFile, jsonEncode(record.toJson())); + } + await atomicWriteString( + layout.taskIndexFile, + jsonEncode({ + 'version': 1, + 'sessions': normalized + .map((item) => item.sessionKey) + .toList(growable: false), + }), + ); + await for (final entity in layout.tasksDirectory.list()) { + if (entity is! File) { + continue; + } + if (entity.path == layout.taskIndexFile.path) { + continue; + } + if (!entity.path.endsWith('.json')) { + continue; + } + if (!keptPaths.contains(entity.path)) { + await entity.delete(); + } + } + } catch (_) { + // Keep the in-memory task cache if the durable write partially fails. + } } - Future clearAssistantLocalState() { - throw StateError( - 'Legacy settings persistence removed. New file-based settings store is pending implementation.', - ); + Future clearAssistantLocalState() async { + await initialize(); + _settingsSnapshot = SettingsSnapshot.defaults(); + _threadRecords = const []; + final layout = _layout; + if (layout == null) { + return; + } + try { + await deleteIfExists(layout.settingsFile); + await deleteIfExists(layout.taskIndexFile); + await for (final entity in layout.tasksDirectory.list()) { + if (entity is File && entity.path.endsWith('.json')) { + await entity.delete(); + } + } + } catch (_) { + // Keep the memory reset even if filesystem cleanup is incomplete. + } } - Future> loadAuditTrail() { - throw StateError( - 'Legacy settings persistence removed. New file-based settings store is pending implementation.', - ); + Future> loadAuditTrail() async { + await initialize(); + return List.from(_auditTrail); } - Future appendAudit(SecretAuditEntry entry) { - throw StateError( - 'Legacy settings persistence removed. New file-based settings store is pending implementation.', - ); + Future appendAudit(SecretAuditEntry entry) async { + await initialize(); + final next = [entry, ..._auditTrail]; + if (next.length > 40) { + next.removeRange(40, next.length); + } + _auditTrail = next; + final layout = _layout; + if (layout == null) { + return; + } + try { + await atomicWriteString( + layout.auditFile, + jsonEncode(next.map((item) => item.toJson()).toList(growable: false)), + ); + } catch (_) { + // Preserve the in-memory audit trail if the durable write fails. + } } void dispose() {} + + Future _readSettingsSnapshot() async { + final layout = _layout; + if (layout == null || !await layout.settingsFile.exists()) { + return SettingsSnapshot.defaults(); + } + try { + final raw = await layout.settingsFile.readAsString(); + final decoded = decodeYamlDocument(raw); + if (decoded is Map) { + return SettingsSnapshot.fromJson(decoded.cast()); + } + } catch (_) {} + return SettingsSnapshot.defaults(); + } + + Future> _readAssistantThreadRecords() async { + final layout = _layout; + if (layout == null) { + return const []; + } + final orderedKeys = await _readThreadIndex(layout); + final recordsByKey = {}; + try { + await for (final entity in layout.tasksDirectory.list()) { + if (entity is! File || + entity.path == layout.taskIndexFile.path || + !entity.path.endsWith('.json')) { + continue; + } + try { + final raw = await entity.readAsString(); + final decoded = jsonDecode(raw); + if (decoded is Map) { + final record = AssistantThreadRecord.fromJson(decoded); + if (record.sessionKey.trim().isNotEmpty) { + recordsByKey[record.sessionKey] = record; + } + } + } catch (_) { + continue; + } + } + } catch (_) { + return const []; + } + final ordered = []; + for (final sessionKey in orderedKeys) { + final record = recordsByKey.remove(sessionKey); + if (record != null) { + ordered.add(record); + } + } + final leftovers = recordsByKey.keys.toList()..sort(); + for (final sessionKey in leftovers) { + final record = recordsByKey[sessionKey]; + if (record != null) { + ordered.add(record); + } + } + return ordered; + } + + Future> _readThreadIndex(StoreLayout layout) async { + if (!await layout.taskIndexFile.exists()) { + return const []; + } + try { + final raw = await layout.taskIndexFile.readAsString(); + final decoded = jsonDecode(raw); + if (decoded is Map) { + final sessions = decoded['sessions']; + if (sessions is List) { + return sessions + .map((item) => item.toString().trim()) + .where((item) => item.isNotEmpty) + .toList(growable: false); + } + } + } catch (_) {} + return const []; + } + + Future> _readAuditTrail() async { + final layout = _layout; + if (layout == null || !await layout.auditFile.exists()) { + return const []; + } + try { + final raw = await layout.auditFile.readAsString(); + final decoded = jsonDecode(raw); + if (decoded is List) { + return decoded + .whereType() + .map( + (item) => SecretAuditEntry.fromJson(item.cast()), + ) + .toList(growable: false); + } + } catch (_) {} + return const []; + } } diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index b61ca246..64a0ecea 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,17 +7,9 @@ #include "generated_plugin_registrant.h" #include -#include -#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); - g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); - flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); - g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); - sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index a8c56e2a..2db3c22a 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,8 +4,6 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_linux - flutter_secure_storage_linux - sqlite3_flutter_libs ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 9bcf621d..e0ddee57 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,16 +7,12 @@ import Foundation import device_info_plus import file_selector_macos -import flutter_secure_storage_macos import package_info_plus import shared_preferences_foundation -import sqlite3_flutter_libs func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) - FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) - Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index e2d1cb28..dcec7904 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -224,53 +224,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.7+1" - flutter_secure_storage: - dependency: "direct main" - description: - name: flutter_secure_storage - sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" - url: "https://pub.dev" - source: hosted - version: "9.2.4" - flutter_secure_storage_linux: - dependency: transitive - description: - name: flutter_secure_storage_linux - sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 - url: "https://pub.dev" - source: hosted - version: "1.2.3" - flutter_secure_storage_macos: - dependency: transitive - description: - name: flutter_secure_storage_macos - sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" - url: "https://pub.dev" - source: hosted - version: "3.1.3" - flutter_secure_storage_platform_interface: - dependency: transitive - description: - name: flutter_secure_storage_platform_interface - sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 - url: "https://pub.dev" - source: hosted - version: "1.1.2" - flutter_secure_storage_web: - dependency: transitive - description: - name: flutter_secure_storage_web - sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 - url: "https://pub.dev" - source: hosted - version: "1.2.1" - flutter_secure_storage_windows: - dependency: "direct overridden" - description: - path: "third_party/flutter_secure_storage_windows" - relative: true - source: path - version: "3.1.2" flutter_test: dependency: "direct dev" description: flutter @@ -331,14 +284,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.20.2" - js: - dependency: transitive - description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 - url: "https://pub.dev" - source: hosted - version: "0.6.7" leak_tracker: dependency: transitive description: @@ -600,22 +545,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" - sqlite3: - dependency: "direct main" - description: - name: sqlite3 - sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2" - url: "https://pub.dev" - source: hosted - version: "2.9.4" - sqlite3_flutter_libs: - dependency: "direct main" - description: - name: sqlite3_flutter_libs - sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad - url: "https://pub.dev" - source: hosted - version: "0.5.42" stack_trace: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ccb8a03d..f0e460ce 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,21 +21,14 @@ dependencies: ffi: ^2.1.4 file_selector: ^1.0.3 flutter_markdown: ^0.7.7+1 - flutter_secure_storage: ^9.2.4 http: ^1.5.0 markdown: ^7.3.0 package_info_plus: ^8.3.1 path_provider: ^2.1.5 shared_preferences: ^2.5.3 - sqlite3: ^2.9.3 - sqlite3_flutter_libs: ^0.5.39 web_socket_channel: ^3.0.3 yaml: ^3.1.3 -dependency_overrides: - flutter_secure_storage_windows: - path: third_party/flutter_secure_storage_windows - dev_dependencies: flutter_test: sdk: flutter diff --git a/test/runtime/secure_config_store_suite.dart b/test/runtime/secure_config_store_suite.dart index 463e6e52..17dc03b8 100644 --- a/test/runtime/secure_config_store_suite.dart +++ b/test/runtime/secure_config_store_suite.dart @@ -210,32 +210,31 @@ void main() { ); test( - 'SecureConfigStore defaults to fail-fast when durable settings path cannot be opened', + 'SecureConfigStore keeps settings in memory when no durable path is available', () async { SharedPreferences.setMockInitialValues({}); - final tempDirectory = await Directory.systemTemp.createTemp( - 'xworkmate-config-store-fail-fast-', - ); - addTearDown(() async { - if (await tempDirectory.exists()) { - await tempDirectory.delete(recursive: true); - } - }); + const unavailablePath = '/dev/null/xworkmate/settings.sqlite3'; final store = SecureConfigStore( - databasePathResolver: () async => - '${tempDirectory.path}/settings.sqlite3', - databaseOpener: (_) => throw StateError('sqlite open failed'), + databasePathResolver: () async => unavailablePath, + fallbackDirectoryPathResolver: () async => + '/dev/null/xworkmate/secrets', + ); + final snapshot = SettingsSnapshot.defaults().copyWith( + accountUsername: 'memory-user', ); - await expectLater( - store.loadSettingsSnapshot(), - throwsA( - isA().having( - (error) => error.message, - 'message', - contains('Durable settings storage unavailable'), - ), - ), + await store.saveSettingsSnapshot(snapshot); + final loadedSnapshot = await store.loadSettingsSnapshot(); + final reloadedSnapshot = await SecureConfigStore( + databasePathResolver: () async => unavailablePath, + fallbackDirectoryPathResolver: () async => + '/dev/null/xworkmate/secrets', + ).loadSettingsSnapshot(); + + expect(loadedSnapshot.accountUsername, 'memory-user'); + expect( + reloadedSnapshot.accountUsername, + SettingsSnapshot.defaults().accountUsername, ); }, ); @@ -272,10 +271,15 @@ void main() { SettingsSnapshot.defaults().accountUsername, ); expect( - await Directory('${tempDirectory.path}/settings').exists(), + await Directory('${tempDirectory.path}/settings/config').exists(), isTrue, ); - expect(await File(explicitSettingsPath).exists(), isTrue); + expect( + await File( + '${tempDirectory.path}/settings/config/settings.yaml', + ).exists(), + isFalse, + ); }, ); @@ -311,7 +315,7 @@ void main() { ); test( - 'SecureConfigStore persists across instances using default support fallback when primary resolvers fail', + 'SecureConfigStore persists across instances using default support root when overrides fail', () async { SharedPreferences.setMockInitialValues({}); final tempDirectory = await Directory.systemTemp.createTemp( @@ -348,73 +352,50 @@ void main() { final loadedSnapshot = await secondStore.loadSettingsSnapshot(); final loadedToken = await secondStore.loadGatewayToken(); - final databaseFile = File( - '$defaultSupportRoot/${SettingsStore.databaseFileName}', - ); + final settingsFile = File('$defaultSupportRoot/config/settings.yaml'); + final secretDirectory = Directory('$defaultSupportRoot/secrets'); - expect(await databaseFile.exists(), isTrue); + expect(await settingsFile.exists(), isTrue); + expect(await secretDirectory.exists(), isTrue); expect(loadedSnapshot.accountUsername, 'fallback-user'); expect(loadedToken, 'fallback-token'); }, ); - test( - 'SecureConfigStore migrates legacy secret fallback files into primary secure storage', - () async { - SharedPreferences.setMockInitialValues({}); - final tempDirectory = await Directory.systemTemp.createTemp( - 'xworkmate-config-store-secret-fallback-', - ); - addTearDown(() async { - if (await tempDirectory.exists()) { - await tempDirectory.delete(recursive: true); - } - }); - final secureStorage = _MapSecureStorageClient(); - final store = SecureConfigStore( - fallbackDirectoryPathResolver: () async => tempDirectory.path, - secureStorage: secureStorage, - ); + test('SecureConfigStore writes secrets into the fixed secret path', () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-config-store-secret-path-', + ); + addTearDown(() async { + if (await tempDirectory.exists()) { + await tempDirectory.delete(recursive: true); + } + }); + final store = SecureConfigStore( + fallbackDirectoryPathResolver: () async => + '${tempDirectory.path}/secrets', + ); - await File( - '${tempDirectory.path}/gateway-token.txt', - ).writeAsString('token-secret', flush: true); - await File( - '${tempDirectory.path}/gateway-password.txt', - ).writeAsString('password-secret', flush: true); - await File( - '${tempDirectory.path}/ai-gateway-api-key.txt', - ).writeAsString('ai-gateway-secret', flush: true); + await store.saveGatewayToken('token-secret'); + await store.saveGatewayPassword('password-secret'); + await store.saveAiGatewayApiKey('ai-gateway-secret'); - expect(await store.loadGatewayToken(), 'token-secret'); - expect(await store.loadGatewayPassword(), 'password-secret'); - expect(await store.loadAiGatewayApiKey(), 'ai-gateway-secret'); - expect(secureStorage._values['xworkmate.gateway.token'], 'token-secret'); - expect( - secureStorage._values['xworkmate.gateway.password'], - 'password-secret', - ); - expect( - secureStorage._values['xworkmate.ai_gateway.api_key'], - 'ai-gateway-secret', - ); - expect( - await File('${tempDirectory.path}/gateway-token.txt').exists(), - isFalse, - ); - expect( - await File('${tempDirectory.path}/gateway-password.txt').exists(), - isFalse, - ); - expect( - await File('${tempDirectory.path}/ai-gateway-api-key.txt').exists(), - isFalse, - ); - }, - ); + expect(await store.loadGatewayToken(), 'token-secret'); + expect(await store.loadGatewayPassword(), 'password-secret'); + expect(await store.loadAiGatewayApiKey(), 'ai-gateway-secret'); + final secretFiles = await Directory( + '${tempDirectory.path}/secrets', + ).list().where((entity) => entity is File).toList(); + expect(secretFiles, hasLength(3)); + expect( + secretFiles.every((entity) => entity.path.endsWith('.secret')), + isTrue, + ); + }); test( - 'SecureConfigStore fails fast and keeps stray local-state files untouched when sqlite is unavailable', + 'SecureConfigStore ignores legacy local-state files and keeps them untouched', () async { SharedPreferences.setMockInitialValues({}); final tempDirectory = await Directory.systemTemp.createTemp( @@ -434,19 +415,16 @@ void main() { final firstStore = SecureConfigStore( databasePathResolver: () async => databasePath, fallbackDirectoryPathResolver: () async => tempDirectory.path, - databaseOpener: (_) => throw StateError('sqlite unavailable'), ); - await expectLater( - firstStore.loadSettingsSnapshot(), - throwsA( - isA().having( - (error) => error.message, - 'message', - contains('sqlite unavailable'), - ), - ), + final loadedSnapshot = await firstStore.loadSettingsSnapshot(); + final loadedThreads = await firstStore.loadAssistantThreadRecords(); + + expect( + loadedSnapshot.accountUsername, + SettingsSnapshot.defaults().accountUsername, ); + expect(loadedThreads, isEmpty); expect(await settingsFile.exists(), isTrue); expect(await threadsFile.exists(), isTrue); }, @@ -1048,22 +1026,3 @@ void main() { }, ); } - -class _MapSecureStorageClient implements SecureStorageClient { - final Map _values = {}; - - @override - Future delete({required String key}) async { - _values.remove(key); - } - - @override - Future read({required String key}) async { - return _values[key]; - } - - @override - Future write({required String key, required String value}) async { - _values[key] = value; - } -} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 89c9c26b..77ab7a09 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,14 +7,8 @@ #include "generated_plugin_registrant.h" #include -#include -#include void RegisterPlugins(flutter::PluginRegistry* registry) { FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); - FlutterSecureStorageWindowsPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); - Sqlite3FlutterLibsPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 1bfb0cc2..a423a024 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,8 +4,6 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows - flutter_secure_storage_windows - sqlite3_flutter_libs ) list(APPEND FLUTTER_FFI_PLUGIN_LIST