Rebuild desktop persistence as file stores
This commit is contained in:
parent
b1995bb0db
commit
170fd3ee0f
@ -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
|
||||
|
||||
117
docs/howto/persistence-storage-layout.md
Normal file
117
docs/howto/persistence-storage-layout.md
Normal 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
|
||||
- 验证磁盘不可用时保留内存态,不发生崩溃
|
||||
325
lib/runtime/file_store_support.dart
Normal file
325
lib/runtime/file_store_support.dart
Normal 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'";
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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>[];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"))
|
||||
}
|
||||
|
||||
71
pubspec.lock
71
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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user