Rebuild desktop persistence as file stores

This commit is contained in:
Haitao Pan 2026-03-23 21:45:36 +08:00
parent b1995bb0db
commit 170fd3ee0f
14 changed files with 1100 additions and 342 deletions

View File

@ -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

View File

@ -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/<App Support>/xworkmate/
```
运行时布局:
```text
xworkmate/
config/
settings.yaml
secret-audit.json
tasks/
index.json
<sessionKey-encoded>.json
secrets/
<key-encoded>.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/<sessionKey-encoded>.json`
- 每个线程会话一个文件
- 文件内容为 `AssistantThreadRecord.toJson()`
- 文件名不直接使用原始 `sessionKey`,而是稳定编码后的结果,避免跨平台文件名问题
- 记录内容里的 `sessionKey` 仍保持原值,不修改模型
### `secrets/<key-encoded>.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/<session>.json` 跨实例可恢复
- 验证 `clearAssistantLocalState()` 不删 secrets
- 验证磁盘不可用时保留内存态,不发生崩溃

View File

@ -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<String?> Function()? localRootPathResolver,
Future<String?> Function()? secretRootPathResolver,
Future<String?> Function()? supportRootPathResolver,
}) : _localRootPathResolver = localRootPathResolver,
_secretRootPathResolver = secretRootPathResolver,
_supportRootPathResolver = supportRootPathResolver;
final Future<String?> Function()? _localRootPathResolver;
final Future<String?> Function()? _secretRootPathResolver;
final Future<String?> Function()? _supportRootPathResolver;
StoreLayout? _cached;
Future<StoreLayout> 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<String?> _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<String?> _resolvePath(Future<String?> 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<Directory> 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<void> 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<void> 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 = <String>{'null', 'true', 'false', '~'};
if (safe.hasMatch(stringValue) && !reserved.contains(stringValue)) {
return stringValue;
}
final escaped = stringValue.replaceAll("'", "''");
return "'$escaped'";
}

View File

@ -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<void> delete({required String 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()) {
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('Secret directory unavailable for $key');
}
await atomicWriteString(file, '$value\n');
}
Future<File?> _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<String?> Function()? fallbackDirectoryPathResolver,
@ -15,94 +63,197 @@ class SecretStore {
Future<String?> 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<void> 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<String?> loadGatewayToken({int? profileIndex}) {
throw StateError(
'Legacy secret persistence removed. New secret-path store is pending implementation.',
);
final StoreLayoutResolver _layoutResolver;
final SecureStorageClient? _secureStorageOverride;
final Map<String, String> _memorySecure = <String, String>{};
StoreLayout? _layout;
SecureStorageClient? _secureStorage;
bool _initialized = false;
Future<void> 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<void> saveGatewayToken(String value, {int? profileIndex}) {
throw StateError(
'Legacy secret persistence removed. New secret-path store is pending implementation.',
);
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> clearGatewayToken({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<String?> loadGatewayPassword({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<void> saveGatewayPassword(String value, {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> clearGatewayPassword({int? profileIndex}) {
throw StateError(
'Legacy secret persistence removed. New secret-path store is pending implementation.',
);
}
Future<String?> loadOllamaCloudApiKey() => _readSecure(_ollamaCloudApiKeyKey);
Future<String?> loadOllamaCloudApiKey() {
throw StateError(
'Legacy secret persistence removed. New secret-path store is pending implementation.',
);
}
Future<void> saveOllamaCloudApiKey(String value) =>
_writeSecure(_ollamaCloudApiKeyKey, value);
Future<void> saveOllamaCloudApiKey(String value) {
throw StateError(
'Legacy secret persistence removed. New secret-path store is pending implementation.',
);
}
Future<String?> loadVaultToken() => _readSecure(_vaultTokenKey);
Future<String?> loadVaultToken() {
throw StateError(
'Legacy secret persistence removed. New secret-path store is pending implementation.',
);
}
Future<void> saveVaultToken(String value) =>
_writeSecure(_vaultTokenKey, value);
Future<void> saveVaultToken(String value) {
throw StateError(
'Legacy secret persistence removed. New secret-path store is pending implementation.',
);
}
Future<String?> loadAiGatewayApiKey() => _readSecure(_aiGatewayApiKeyKey);
Future<String?> loadAiGatewayApiKey() {
throw StateError(
'Legacy secret persistence removed. New secret-path store is pending implementation.',
);
}
Future<void> saveAiGatewayApiKey(String value) =>
_writeSecure(_aiGatewayApiKeyKey, value);
Future<void> saveAiGatewayApiKey(String value) {
throw StateError(
'Legacy secret persistence removed. New secret-path store is pending implementation.',
);
}
Future<void> clearAiGatewayApiKey() => _deleteSecure(_aiGatewayApiKeyKey);
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.',
);
Future<Map<String, String>> loadSecureRefs() async {
await initialize();
final secureRefs = <String, String>{};
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<LocalDeviceIdentity?> loadDeviceIdentity() {
throw StateError(
'Legacy secret persistence removed. New secret-path store is pending implementation.',
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;
}
final createdAtMs =
int.tryParse(await _readSecure(_gatewayDeviceCreatedAtKey) ?? '') ?? 0;
return LocalDeviceIdentity(
deviceId: deviceId,
publicKeyBase64Url: publicKey,
privateKeyBase64Url: privateKey,
createdAtMs: createdAtMs,
);
}
Future<void> saveDeviceIdentity(LocalDeviceIdentity identity) {
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,
);
await _writeSecure(
_gatewayDeviceCreatedAtKey,
identity.createdAtMs.toString(),
);
}
Future<String?> 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<void> 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<void> 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<List<int>?> loadLegacyLocalStateKeyBytes() async {
final encoded = await _readSecure(legacyLocalStateKey);
final trimmed = encoded?.trim() ?? '';
if (trimmed.isEmpty) {
return null;
}
return _base64UrlDecode(trimmed);
}
Future<List<int>?> loadLegacyLocalStateKeyBytes() {
throw StateError(
'Legacy secret persistence removed. New secret-path store is pending implementation.',
);
Future<void> dispose() async {
_memorySecure.clear();
_secureStorage = null;
_layout = null;
_initialized = false;
}
Future<void> 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<int> _gatewayProfileFallbackOrder = <int>[
kGatewayRemoteProfileIndex,
kGatewayLocalProfileIndex,
2,
3,
4,
];
Future<String?> _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<void> _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<void> _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<int> _base64UrlDecode(String value) {
final normalized = value.replaceAll('-', '+').replaceAll('_', '/');
final padded = normalized + '=' * ((4 - normalized.length % 4) % 4);
return base64.decode(padded);
}
}

View File

@ -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,
);
}

View File

@ -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<Object?> Function(
String resolvedPath,
);
typedef SecureConfigDatabaseOpener =
FutureOr<Object?> Function(String resolvedPath);
class SettingsStore {
SettingsStore({
@ -12,7 +14,13 @@ class SettingsStore {
Future<String?> Function()? databasePathResolver,
Future<String?> 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<void> initialize() async {}
final StoreLayoutResolver _layoutResolver;
bool _initialized = false;
StoreLayout? _layout;
SettingsSnapshot _settingsSnapshot = SettingsSnapshot.defaults();
List<AssistantThreadRecord> _threadRecords = const <AssistantThreadRecord>[];
List<SecretAuditEntry> _auditTrail = const <SecretAuditEntry>[];
Future<SettingsSnapshot> loadSettingsSnapshot() {
throw StateError(
'Legacy settings persistence removed. New file-based settings store is pending implementation.',
);
Future<void> 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<void> saveSettingsSnapshot(SettingsSnapshot snapshot) {
throw StateError(
'Legacy settings persistence removed. New file-based settings store is pending implementation.',
);
Future<SettingsSnapshot> loadSettingsSnapshot() async {
await initialize();
return _settingsSnapshot;
}
Future<List<AssistantThreadRecord>> loadAssistantThreadRecords() {
throw StateError(
'Legacy settings persistence removed. New file-based settings store is pending implementation.',
);
Future<void> 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<List<AssistantThreadRecord>> loadAssistantThreadRecords() async {
await initialize();
return List<AssistantThreadRecord>.from(_threadRecords);
}
Future<void> saveAssistantThreadRecords(
List<AssistantThreadRecord> 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 = <String>{};
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(<String, dynamic>{
'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<void> clearAssistantLocalState() {
throw StateError(
'Legacy settings persistence removed. New file-based settings store is pending implementation.',
);
Future<void> clearAssistantLocalState() async {
await initialize();
_settingsSnapshot = SettingsSnapshot.defaults();
_threadRecords = const <AssistantThreadRecord>[];
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<List<SecretAuditEntry>> loadAuditTrail() {
throw StateError(
'Legacy settings persistence removed. New file-based settings store is pending implementation.',
);
Future<List<SecretAuditEntry>> loadAuditTrail() async {
await initialize();
return List<SecretAuditEntry>.from(_auditTrail);
}
Future<void> appendAudit(SecretAuditEntry entry) {
throw StateError(
'Legacy settings persistence removed. New file-based settings store is pending implementation.',
);
Future<void> appendAudit(SecretAuditEntry entry) async {
await initialize();
final next = <SecretAuditEntry>[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<SettingsSnapshot> _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<String, dynamic>());
}
} catch (_) {}
return SettingsSnapshot.defaults();
}
Future<List<AssistantThreadRecord>> _readAssistantThreadRecords() async {
final layout = _layout;
if (layout == null) {
return const <AssistantThreadRecord>[];
}
final orderedKeys = await _readThreadIndex(layout);
final recordsByKey = <String, AssistantThreadRecord>{};
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<String, dynamic>) {
final record = AssistantThreadRecord.fromJson(decoded);
if (record.sessionKey.trim().isNotEmpty) {
recordsByKey[record.sessionKey] = record;
}
}
} catch (_) {
continue;
}
}
} catch (_) {
return const <AssistantThreadRecord>[];
}
final ordered = <AssistantThreadRecord>[];
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<List<String>> _readThreadIndex(StoreLayout layout) async {
if (!await layout.taskIndexFile.exists()) {
return const <String>[];
}
try {
final raw = await layout.taskIndexFile.readAsString();
final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) {
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 <String>[];
}
Future<List<SecretAuditEntry>> _readAuditTrail() async {
final layout = _layout;
if (layout == null || !await layout.auditFile.exists()) {
return const <SecretAuditEntry>[];
}
try {
final raw = await layout.auditFile.readAsString();
final decoded = jsonDecode(raw);
if (decoded is List) {
return decoded
.whereType<Map>()
.map(
(item) => SecretAuditEntry.fromJson(item.cast<String, dynamic>()),
)
.toList(growable: false);
}
} catch (_) {}
return const <SecretAuditEntry>[];
}
}

View File

@ -7,17 +7,9 @@
#include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
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);
}

View File

@ -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

View File

@ -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"))
}

View File

@ -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:

View File

@ -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

View File

@ -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(<String, Object>{});
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<StateError>().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(<String, Object>{});
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(<String, Object>{});
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(<String, Object>{});
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(<String, Object>{});
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<StateError>().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<String, String> _values = <String, String>{};
@override
Future<void> delete({required String key}) async {
_values.remove(key);
}
@override
Future<String?> read({required String key}) async {
return _values[key];
}
@override
Future<void> write({required String key, required String value}) async {
_values[key] = value;
}
}

View File

@ -7,14 +7,8 @@
#include "generated_plugin_registrant.h"
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
Sqlite3FlutterLibsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin"));
}

View File

@ -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