Remove legacy settings recovery path
This commit is contained in:
parent
33d08b5a5f
commit
45a8a9d25d
@ -748,7 +748,6 @@ Persistence:
|
||||
- lib/runtime/secure_config_store.dart
|
||||
- lib/runtime/settings_store.dart
|
||||
- lib/runtime/secret_store.dart
|
||||
- lib/runtime/legacy_settings_recovery.dart
|
||||
|
||||
Supporting architecture docs:
|
||||
- docs/architecture/assistant-thread-information-architecture.md
|
||||
|
||||
@ -306,7 +306,6 @@ class AppController extends ChangeNotifier {
|
||||
_draftSecretValues.isNotEmpty;
|
||||
bool get hasPendingSettingsApply => _pendingSettingsApply;
|
||||
String get settingsDraftStatusMessage => _settingsDraftStatusMessage;
|
||||
LegacyRecoveryReport get legacyRecoveryReport => _store.lastRecoveryReport;
|
||||
List<GatewayAgentSummary> get agents => _agentsController.agents;
|
||||
List<GatewaySessionSummary> get sessions => isSingleAgentMode
|
||||
? _assistantSessionSummaries()
|
||||
@ -2704,12 +2703,7 @@ class AppController extends ChangeNotifier {
|
||||
_settingsDraft = settings;
|
||||
_lastAppliedSettings = settings;
|
||||
_settingsDraftInitialized = true;
|
||||
_settingsDraftStatusMessage = legacyRecoveryReport.hasIssue
|
||||
? appText(
|
||||
'检测到旧版本配置,但当前版本无法解锁旧加密状态。',
|
||||
'Detected legacy settings, but this build could not unlock the old encrypted state.',
|
||||
)
|
||||
: '';
|
||||
_settingsDraftStatusMessage = '';
|
||||
} catch (error) {
|
||||
if (_disposed) {
|
||||
return;
|
||||
|
||||
@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../i18n/app_language.dart';
|
||||
import '../models/app_models.dart';
|
||||
import '../runtime/legacy_settings_recovery.dart';
|
||||
import '../runtime/runtime_models.dart';
|
||||
import '../web/web_ai_gateway_client.dart';
|
||||
import '../web/web_relay_gateway_client.dart';
|
||||
@ -86,7 +85,6 @@ class AppController extends ChangeNotifier {
|
||||
_draftSecretValues.isNotEmpty;
|
||||
bool get hasPendingSettingsApply => _pendingSettingsApply;
|
||||
String get settingsDraftStatusMessage => _settingsDraftStatusMessage;
|
||||
LegacyRecoveryReport get legacyRecoveryReport => const LegacyRecoveryReport();
|
||||
AppLanguage get appLanguage => _settings.appLanguage;
|
||||
GatewayConnectionSnapshot get connection => _relayClient.snapshot;
|
||||
bool get relayBusy => _relayBusy;
|
||||
|
||||
@ -400,13 +400,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
final theme = Theme.of(context);
|
||||
final hasDraft = controller.hasSettingsDraftChanges;
|
||||
final hasPendingApply = controller.hasPendingSettingsApply;
|
||||
final recoveryIssue = controller.legacyRecoveryReport.hasIssue;
|
||||
final message = recoveryIssue
|
||||
? appText(
|
||||
'检测到旧版本配置,但当前版本无法解锁旧加密状态。',
|
||||
'Detected legacy settings, but this build could not unlock the old encrypted state.',
|
||||
)
|
||||
: controller.settingsDraftStatusMessage;
|
||||
final message = controller.settingsDraftStatusMessage;
|
||||
return SurfaceCard(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@ -421,7 +415,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
recoveryIssue
|
||||
message.isNotEmpty
|
||||
? message
|
||||
: hasDraft
|
||||
? appText(
|
||||
@ -451,14 +445,14 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
children: [
|
||||
OutlinedButton(
|
||||
key: const ValueKey('settings-global-save-button'),
|
||||
onPressed: (!hasDraft && !recoveryIssue)
|
||||
? null
|
||||
: () => _handleTopLevelSave(controller),
|
||||
onPressed: hasDraft
|
||||
? () => _handleTopLevelSave(controller)
|
||||
: null,
|
||||
child: Text(appText('保存', 'Save')),
|
||||
),
|
||||
FilledButton.tonal(
|
||||
key: const ValueKey('settings-global-apply-button'),
|
||||
onPressed: (!hasDraft && !hasPendingApply && !recoveryIssue)
|
||||
onPressed: (!hasDraft && !hasPendingApply)
|
||||
? null
|
||||
: () => _handleTopLevelApply(controller),
|
||||
child: Text(appText('应用', 'Apply')),
|
||||
|
||||
@ -1,58 +0,0 @@
|
||||
enum LegacyRecoveryStatus {
|
||||
none,
|
||||
migrated,
|
||||
lockedLegacyState,
|
||||
failed,
|
||||
}
|
||||
|
||||
extension LegacyRecoveryStatusCopy on LegacyRecoveryStatus {
|
||||
static LegacyRecoveryStatus fromJsonValue(String? value) {
|
||||
return switch (value?.trim()) {
|
||||
'migrated' => LegacyRecoveryStatus.migrated,
|
||||
'locked_legacy_state' => LegacyRecoveryStatus.lockedLegacyState,
|
||||
'failed' => LegacyRecoveryStatus.failed,
|
||||
_ => LegacyRecoveryStatus.none,
|
||||
};
|
||||
}
|
||||
|
||||
String get jsonValue => switch (this) {
|
||||
LegacyRecoveryStatus.none => 'none',
|
||||
LegacyRecoveryStatus.migrated => 'migrated',
|
||||
LegacyRecoveryStatus.lockedLegacyState => 'locked_legacy_state',
|
||||
LegacyRecoveryStatus.failed => 'failed',
|
||||
};
|
||||
}
|
||||
|
||||
class LegacyRecoveryReport {
|
||||
const LegacyRecoveryReport({
|
||||
this.status = LegacyRecoveryStatus.none,
|
||||
this.sourcePath,
|
||||
this.details = '',
|
||||
});
|
||||
|
||||
final LegacyRecoveryStatus status;
|
||||
final String? sourcePath;
|
||||
final String details;
|
||||
|
||||
bool get hasIssue =>
|
||||
status == LegacyRecoveryStatus.lockedLegacyState ||
|
||||
status == LegacyRecoveryStatus.failed;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'status': status.jsonValue,
|
||||
'sourcePath': sourcePath,
|
||||
'details': details,
|
||||
};
|
||||
}
|
||||
|
||||
factory LegacyRecoveryReport.fromJson(Map<String, dynamic> json) {
|
||||
return LegacyRecoveryReport(
|
||||
status: LegacyRecoveryStatusCopy.fromJsonValue(
|
||||
json['status'] as String?,
|
||||
),
|
||||
sourcePath: json['sourcePath'] as String?,
|
||||
details: json['details'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,10 +1,8 @@
|
||||
import 'dart:io';
|
||||
|
||||
export 'legacy_settings_recovery.dart';
|
||||
export 'secret_store.dart';
|
||||
export 'settings_store.dart';
|
||||
|
||||
import 'legacy_settings_recovery.dart';
|
||||
import 'runtime_models.dart';
|
||||
import 'secret_store.dart';
|
||||
import 'settings_store.dart';
|
||||
@ -35,16 +33,12 @@ class SecureConfigStore {
|
||||
defaultSupportDirectoryPathResolver:
|
||||
resolvedDefaultSupportDirectoryPathResolver,
|
||||
databaseOpener: databaseOpener,
|
||||
legacyLocalStateKeyLoader: _secretStore.loadLegacyLocalStateKeyBytes,
|
||||
);
|
||||
}
|
||||
|
||||
late final SecretStore _secretStore;
|
||||
late final SettingsStore _settingsStore;
|
||||
|
||||
LegacyRecoveryReport get lastRecoveryReport =>
|
||||
_settingsStore.lastRecoveryReport;
|
||||
|
||||
Future<void> initialize() async {
|
||||
await _secretStore.initialize();
|
||||
await _settingsStore.initialize();
|
||||
|
||||
@ -2,12 +2,9 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:sqlite3/sqlite3.dart' as sqlite;
|
||||
|
||||
import 'legacy_settings_recovery.dart';
|
||||
import 'runtime_models.dart';
|
||||
|
||||
typedef SecureConfigDatabaseOpener =
|
||||
@ -19,58 +16,36 @@ class SettingsStore {
|
||||
Future<String?> Function()? databasePathResolver,
|
||||
Future<String?> Function()? defaultSupportDirectoryPathResolver,
|
||||
SecureConfigDatabaseOpener? databaseOpener,
|
||||
Future<List<int>?> Function()? legacyLocalStateKeyLoader,
|
||||
}) : _fallbackDirectoryPathResolver = fallbackDirectoryPathResolver,
|
||||
_databasePathResolver = databasePathResolver,
|
||||
_defaultSupportDirectoryPathResolver =
|
||||
defaultSupportDirectoryPathResolver,
|
||||
_databaseOpener = databaseOpener,
|
||||
_legacyLocalStateKeyLoader = legacyLocalStateKeyLoader;
|
||||
_databaseOpener = databaseOpener;
|
||||
|
||||
static const String settingsKey = 'xworkmate.settings.snapshot';
|
||||
static const String auditKey = 'xworkmate.secrets.audit';
|
||||
static const String assistantThreadsKey = 'xworkmate.assistant.threads';
|
||||
static const String databaseFileName = 'config-store.sqlite3';
|
||||
static const String databaseTableName = 'config_entries';
|
||||
static const String stateBackupFileName = 'assistant-state-backup.json';
|
||||
static const String sealedStateFormat = 'xworkmate.sealed.local-state.v1';
|
||||
|
||||
static const Map<String, String> _durableStateFileNames = <String, String>{
|
||||
settingsKey: 'settings-snapshot.json',
|
||||
assistantThreadsKey: 'assistant-threads.json',
|
||||
};
|
||||
|
||||
final Future<String?> Function()? _fallbackDirectoryPathResolver;
|
||||
final Future<String?> Function()? _databasePathResolver;
|
||||
final Future<String?> Function()? _defaultSupportDirectoryPathResolver;
|
||||
final SecureConfigDatabaseOpener? _databaseOpener;
|
||||
final Future<List<int>?> Function()? _legacyLocalStateKeyLoader;
|
||||
final Cipher _legacyCipher = AesGcm.with256bits();
|
||||
SharedPreferences? _prefs;
|
||||
sqlite.Database? _database;
|
||||
String? _resolvedDatabasePath;
|
||||
bool _initialized = false;
|
||||
bool _recoveryAttempted = false;
|
||||
LegacyRecoveryReport _lastRecoveryReport = const LegacyRecoveryReport();
|
||||
|
||||
LegacyRecoveryReport get lastRecoveryReport => _lastRecoveryReport;
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (_initialized) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
} catch (_) {
|
||||
_prefs = null;
|
||||
}
|
||||
await _initializeDatabase();
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
Future<SettingsSnapshot> loadSettingsSnapshot() async {
|
||||
await initialize();
|
||||
await _ensureLegacyRecoveryIfNeeded();
|
||||
final raw = await _readStoredString(settingsKey);
|
||||
return _decodeSettingsSnapshot(raw) ?? SettingsSnapshot.defaults();
|
||||
}
|
||||
@ -79,12 +54,10 @@ class SettingsStore {
|
||||
await initialize();
|
||||
final encoded = snapshot.toJsonString();
|
||||
await _writeStoredString(settingsKey, encoded);
|
||||
_lastRecoveryReport = const LegacyRecoveryReport();
|
||||
}
|
||||
|
||||
Future<List<AssistantThreadRecord>> loadAssistantThreadRecords() async {
|
||||
await initialize();
|
||||
await _ensureLegacyRecoveryIfNeeded();
|
||||
final raw = await _readStoredString(assistantThreadsKey);
|
||||
return _decodeAssistantThreadRecords(raw) ??
|
||||
const <AssistantThreadRecord>[];
|
||||
@ -104,11 +77,6 @@ class SettingsStore {
|
||||
await initialize();
|
||||
await _deleteStoredString(settingsKey);
|
||||
await _deleteStoredString(assistantThreadsKey);
|
||||
await _deleteDurableStateFile(settingsKey);
|
||||
await _deleteDurableStateFile(assistantThreadsKey);
|
||||
await _deleteLegacyBackupFile();
|
||||
_lastRecoveryReport = const LegacyRecoveryReport();
|
||||
_recoveryAttempted = true;
|
||||
}
|
||||
|
||||
Future<List<SecretAuditEntry>> loadAuditTrail() async {
|
||||
@ -153,7 +121,6 @@ class SettingsStore {
|
||||
// Ignore close errors during teardown.
|
||||
}
|
||||
}
|
||||
_prefs = null;
|
||||
_initialized = false;
|
||||
_resolvedDatabasePath = null;
|
||||
}
|
||||
@ -168,7 +135,6 @@ class SettingsStore {
|
||||
'Durable settings storage unavailable: failed to open $resolvedPath. Cause: $error',
|
||||
);
|
||||
}
|
||||
await _migrateLegacyPrefs();
|
||||
}
|
||||
|
||||
Future<sqlite.Database> _openDatabase(String resolvedPath) async {
|
||||
@ -201,318 +167,6 @@ class SettingsStore {
|
||||
''');
|
||||
}
|
||||
|
||||
Future<void> _migrateLegacyPrefs() async {
|
||||
if (_database == null || _prefs == null) {
|
||||
return;
|
||||
}
|
||||
await _migrateLegacyPrefEntry(settingsKey);
|
||||
await _migrateLegacyPrefEntry(auditKey);
|
||||
await _migrateLegacyPrefEntry(assistantThreadsKey);
|
||||
}
|
||||
|
||||
Future<void> _migrateLegacyPrefEntry(String key) async {
|
||||
if (_database == null || _prefs == null) {
|
||||
return;
|
||||
}
|
||||
final legacyValue = _prefs!.getString(key);
|
||||
if (legacyValue == null || legacyValue.trim().isEmpty) {
|
||||
return;
|
||||
}
|
||||
final existing = _database!.select(
|
||||
'SELECT value FROM $databaseTableName WHERE storage_key = ? LIMIT 1',
|
||||
<Object?>[key],
|
||||
);
|
||||
if (existing.isEmpty) {
|
||||
await _writeStoredString(key, legacyValue);
|
||||
}
|
||||
await _prefs!.remove(key);
|
||||
}
|
||||
|
||||
Future<void> _ensureLegacyRecoveryIfNeeded() async {
|
||||
if (_recoveryAttempted) {
|
||||
return;
|
||||
}
|
||||
_recoveryAttempted = true;
|
||||
|
||||
final currentSettingsRaw = await _readStoredString(settingsKey);
|
||||
final currentThreadsRaw = await _readStoredString(assistantThreadsKey);
|
||||
final hasReadableCurrentState =
|
||||
_decodeSettingsSnapshot(currentSettingsRaw) != null ||
|
||||
_decodeAssistantThreadRecords(currentThreadsRaw) != null;
|
||||
if (hasReadableCurrentState) {
|
||||
_lastRecoveryReport = const LegacyRecoveryReport();
|
||||
return;
|
||||
}
|
||||
|
||||
final recovery = await _attemptLegacyRecovery(
|
||||
currentSettingsRaw: currentSettingsRaw,
|
||||
currentThreadsRaw: currentThreadsRaw,
|
||||
);
|
||||
_lastRecoveryReport = recovery;
|
||||
}
|
||||
|
||||
Future<LegacyRecoveryReport> _attemptLegacyRecovery({
|
||||
required String? currentSettingsRaw,
|
||||
required String? currentThreadsRaw,
|
||||
}) async {
|
||||
final lockedSources = <String>[];
|
||||
final candidates = await _legacyCandidateDirectories();
|
||||
for (final directory in candidates) {
|
||||
final source = await _readLegacySource(directory);
|
||||
if (source.locked) {
|
||||
lockedSources.add(source.sourcePath);
|
||||
}
|
||||
if (source.settings != null || source.threads != null) {
|
||||
final recoveredSettings =
|
||||
source.settings ?? SettingsSnapshot.defaults();
|
||||
final recoveredThreads =
|
||||
source.threads ?? const <AssistantThreadRecord>[];
|
||||
await _writeStoredString(settingsKey, recoveredSettings.toJsonString());
|
||||
await _writeStoredString(
|
||||
assistantThreadsKey,
|
||||
jsonEncode(
|
||||
recoveredThreads
|
||||
.map((item) => item.toJson())
|
||||
.toList(growable: false),
|
||||
),
|
||||
);
|
||||
return LegacyRecoveryReport(
|
||||
status: LegacyRecoveryStatus.migrated,
|
||||
sourcePath: source.sourcePath,
|
||||
details:
|
||||
'Recovered legacy settings into the new plain settings store.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final currentLocked =
|
||||
_isSealedLocalState(currentSettingsRaw) ||
|
||||
_isSealedLocalState(currentThreadsRaw);
|
||||
if (currentLocked || lockedSources.isNotEmpty) {
|
||||
return LegacyRecoveryReport(
|
||||
status: LegacyRecoveryStatus.lockedLegacyState,
|
||||
sourcePath: lockedSources.isNotEmpty ? lockedSources.first : null,
|
||||
details:
|
||||
'Detected legacy encrypted state but could not restore the local-state key.',
|
||||
);
|
||||
}
|
||||
return const LegacyRecoveryReport();
|
||||
}
|
||||
|
||||
Future<List<String>> _legacyCandidateDirectories() async {
|
||||
final databasePath = await _resolveDatabasePath();
|
||||
return <String>[File(databasePath).parent.path];
|
||||
}
|
||||
|
||||
Future<_LegacySourceResult> _readLegacySource(String directoryPath) async {
|
||||
final settingsFromDatabase = await _readLegacyDatabaseEntry(
|
||||
directoryPath,
|
||||
settingsKey,
|
||||
);
|
||||
final threadsFromDatabase = await _readLegacyDatabaseEntry(
|
||||
directoryPath,
|
||||
assistantThreadsKey,
|
||||
);
|
||||
final settingsFromFile = await _readLegacyDurableState(
|
||||
directoryPath,
|
||||
settingsKey,
|
||||
);
|
||||
final threadsFromFile = await _readLegacyDurableState(
|
||||
directoryPath,
|
||||
assistantThreadsKey,
|
||||
);
|
||||
final backup = await _readLegacyBackup(directoryPath);
|
||||
|
||||
final settings =
|
||||
settingsFromDatabase.snapshot ??
|
||||
settingsFromFile.snapshot ??
|
||||
backup.snapshot?.settings;
|
||||
final threads =
|
||||
threadsFromDatabase.threads ??
|
||||
threadsFromFile.threads ??
|
||||
backup.snapshot?.assistantThreads;
|
||||
final locked =
|
||||
settingsFromDatabase.locked ||
|
||||
threadsFromDatabase.locked ||
|
||||
settingsFromFile.locked ||
|
||||
threadsFromFile.locked ||
|
||||
backup.locked;
|
||||
return _LegacySourceResult(
|
||||
sourcePath: directoryPath,
|
||||
settings: settings,
|
||||
threads: threads,
|
||||
locked: locked,
|
||||
);
|
||||
}
|
||||
|
||||
Future<_LegacyStateReadResult> _readLegacyDatabaseEntry(
|
||||
String directoryPath,
|
||||
String key,
|
||||
) async {
|
||||
final databaseFile = File('$directoryPath/$databaseFileName');
|
||||
if (!await databaseFile.exists()) {
|
||||
return const _LegacyStateReadResult();
|
||||
}
|
||||
try {
|
||||
final database =
|
||||
(_database != null &&
|
||||
await _resolveDatabasePath() == databaseFile.path)
|
||||
? _database
|
||||
: sqlite.sqlite3.open(databaseFile.path);
|
||||
final result = database!.select(
|
||||
'SELECT value FROM $databaseTableName WHERE storage_key = ? LIMIT 1',
|
||||
<Object?>[key],
|
||||
);
|
||||
if (!identical(database, _database)) {
|
||||
database.dispose();
|
||||
}
|
||||
if (result.isEmpty) {
|
||||
return const _LegacyStateReadResult();
|
||||
}
|
||||
final raw = result.first['value'] as String?;
|
||||
return _decodeLegacyValue(raw, key);
|
||||
} catch (_) {
|
||||
return const _LegacyStateReadResult();
|
||||
}
|
||||
}
|
||||
|
||||
Future<_LegacyStateReadResult> _readLegacyDurableState(
|
||||
String directoryPath,
|
||||
String key,
|
||||
) async {
|
||||
final fileName = _durableStateFileNames[key];
|
||||
if (fileName == null) {
|
||||
return const _LegacyStateReadResult();
|
||||
}
|
||||
final file = File('$directoryPath/$fileName');
|
||||
if (!await file.exists()) {
|
||||
return const _LegacyStateReadResult();
|
||||
}
|
||||
try {
|
||||
final raw = await file.readAsString();
|
||||
return _decodeLegacyValue(raw, key);
|
||||
} catch (_) {
|
||||
return const _LegacyStateReadResult();
|
||||
}
|
||||
}
|
||||
|
||||
Future<_LegacyBackupReadResult> _readLegacyBackup(
|
||||
String directoryPath,
|
||||
) async {
|
||||
final file = File('$directoryPath/$stateBackupFileName');
|
||||
if (!await file.exists()) {
|
||||
return const _LegacyBackupReadResult();
|
||||
}
|
||||
try {
|
||||
final decoded =
|
||||
jsonDecode(await file.readAsString()) as Map<String, dynamic>;
|
||||
final sealedState = decoded['sealedState'];
|
||||
if (sealedState is String && sealedState.trim().isNotEmpty) {
|
||||
final plaintext = await _decryptLegacyValue(
|
||||
'_assistant_state_backup',
|
||||
sealedState,
|
||||
);
|
||||
if (plaintext == null) {
|
||||
return const _LegacyBackupReadResult(locked: true);
|
||||
}
|
||||
final payload = jsonDecode(plaintext) as Map<String, dynamic>;
|
||||
return _LegacyBackupReadResult(
|
||||
snapshot: _AssistantStateSnapshot(
|
||||
settings: SettingsSnapshot.fromJson(
|
||||
(payload['settings'] as Map?)?.cast<String, dynamic>() ??
|
||||
const {},
|
||||
),
|
||||
assistantThreads:
|
||||
((payload['assistantThreads'] as List?) ?? const [])
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(item) => AssistantThreadRecord.fromJson(
|
||||
item.cast<String, dynamic>(),
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
),
|
||||
);
|
||||
}
|
||||
final settings = SettingsSnapshot.fromJson(
|
||||
(decoded['settings'] as Map?)?.cast<String, dynamic>() ?? const {},
|
||||
);
|
||||
final threads = ((decoded['assistantThreads'] as List?) ?? const [])
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(item) =>
|
||||
AssistantThreadRecord.fromJson(item.cast<String, dynamic>()),
|
||||
)
|
||||
.toList(growable: false);
|
||||
return _LegacyBackupReadResult(
|
||||
snapshot: _AssistantStateSnapshot(
|
||||
settings: settings,
|
||||
assistantThreads: threads,
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
return const _LegacyBackupReadResult();
|
||||
}
|
||||
}
|
||||
|
||||
Future<_LegacyStateReadResult> _decodeLegacyValue(
|
||||
String? raw,
|
||||
String key,
|
||||
) async {
|
||||
final trimmed = raw?.trim() ?? '';
|
||||
if (trimmed.isEmpty) {
|
||||
return const _LegacyStateReadResult();
|
||||
}
|
||||
final plainSettings = key == settingsKey
|
||||
? _decodeSettingsSnapshot(trimmed)
|
||||
: null;
|
||||
final plainThreads = key == assistantThreadsKey
|
||||
? _decodeAssistantThreadRecords(trimmed)
|
||||
: null;
|
||||
if (plainSettings != null || plainThreads != null) {
|
||||
return _LegacyStateReadResult(
|
||||
snapshot: plainSettings,
|
||||
threads: plainThreads,
|
||||
);
|
||||
}
|
||||
if (!_isSealedLocalState(trimmed)) {
|
||||
return const _LegacyStateReadResult();
|
||||
}
|
||||
final decrypted = await _decryptLegacyValue(key, trimmed);
|
||||
if (decrypted == null) {
|
||||
return const _LegacyStateReadResult(locked: true);
|
||||
}
|
||||
return _LegacyStateReadResult(
|
||||
snapshot: key == settingsKey ? _decodeSettingsSnapshot(decrypted) : null,
|
||||
threads: key == assistantThreadsKey
|
||||
? _decodeAssistantThreadRecords(decrypted)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _decryptLegacyValue(String key, String persisted) async {
|
||||
final keyBytes = await _legacyLocalStateKeyLoader?.call();
|
||||
if (keyBytes == null || keyBytes.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final envelope = jsonDecode(persisted) as Map<String, dynamic>;
|
||||
final secretBox = SecretBox(
|
||||
_base64UrlDecode(envelope['cipherText'] as String? ?? ''),
|
||||
nonce: _base64UrlDecode(envelope['nonce'] as String? ?? ''),
|
||||
mac: Mac(_base64UrlDecode(envelope['mac'] as String? ?? '')),
|
||||
);
|
||||
final clearText = await _legacyCipher.decrypt(
|
||||
secretBox,
|
||||
secretKey: SecretKey(keyBytes),
|
||||
aad: utf8.encode(key),
|
||||
);
|
||||
return utf8.decode(clearText);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _resolveDatabasePath() async {
|
||||
final resolved = _resolvedDatabasePath?.trim() ?? '';
|
||||
if (resolved.isNotEmpty) {
|
||||
@ -544,7 +198,9 @@ class SettingsStore {
|
||||
|
||||
Future<String?> _readStoredString(String key) async {
|
||||
if (_database == null) {
|
||||
throw StateError('Durable settings storage unavailable: database not initialized.');
|
||||
throw StateError(
|
||||
'Durable settings storage unavailable: database not initialized.',
|
||||
);
|
||||
}
|
||||
try {
|
||||
final result = _database!.select(
|
||||
@ -569,7 +225,9 @@ class SettingsStore {
|
||||
return;
|
||||
}
|
||||
if (_database == null) {
|
||||
throw StateError('Durable settings storage unavailable: database not initialized.');
|
||||
throw StateError(
|
||||
'Durable settings storage unavailable: database not initialized.',
|
||||
);
|
||||
}
|
||||
try {
|
||||
_database!.execute(
|
||||
@ -591,7 +249,9 @@ class SettingsStore {
|
||||
|
||||
Future<void> _deleteStoredString(String key) async {
|
||||
if (_database == null) {
|
||||
throw StateError('Durable settings storage unavailable: database not initialized.');
|
||||
throw StateError(
|
||||
'Durable settings storage unavailable: database not initialized.',
|
||||
);
|
||||
}
|
||||
try {
|
||||
_database!.execute(
|
||||
@ -603,37 +263,6 @@ class SettingsStore {
|
||||
'Durable settings storage unavailable: failed to delete $key from $_resolvedDatabasePath.',
|
||||
);
|
||||
}
|
||||
try {
|
||||
await _prefs?.remove(key);
|
||||
} catch (_) {
|
||||
// Ignore.
|
||||
}
|
||||
}
|
||||
|
||||
Future<File?> _durableStateFile(String key) async {
|
||||
final fileName = _durableStateFileNames[key];
|
||||
if (fileName == null) {
|
||||
return null;
|
||||
}
|
||||
final databasePath = await _resolveDatabasePath();
|
||||
final directory = File(databasePath).parent;
|
||||
return File('${directory.path}/$fileName');
|
||||
}
|
||||
|
||||
Future<void> _deleteDurableStateFile(String key) async {
|
||||
final file = await _durableStateFile(key);
|
||||
if (file == null || !await file.exists()) {
|
||||
return;
|
||||
}
|
||||
await file.delete();
|
||||
}
|
||||
|
||||
Future<void> _deleteLegacyBackupFile() async {
|
||||
final databasePath = await _resolveDatabasePath();
|
||||
final file = File('${File(databasePath).parent.path}/$stateBackupFileName');
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSnapshot? _decodeSettingsSnapshot(String? raw) {
|
||||
@ -647,8 +276,7 @@ class SettingsStore {
|
||||
return null;
|
||||
}
|
||||
final decoded = decodedValue.cast<String, dynamic>();
|
||||
if (decoded['storageFormat'] == sealedStateFormat ||
|
||||
!_looksLikeSettingsSnapshot(decoded)) {
|
||||
if (!_looksLikeSettingsSnapshot(decoded)) {
|
||||
return null;
|
||||
}
|
||||
return SettingsSnapshot.fromJson(decoded);
|
||||
@ -676,26 +304,6 @@ class SettingsStore {
|
||||
}
|
||||
}
|
||||
|
||||
bool _isSealedLocalState(String? value) {
|
||||
final trimmed = value?.trim() ?? '';
|
||||
if (trimmed.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
final decoded = jsonDecode(trimmed);
|
||||
return decoded is Map<String, dynamic> &&
|
||||
decoded['storageFormat'] == sealedStateFormat;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static List<int> _base64UrlDecode(String value) {
|
||||
final normalized = value.replaceAll('-', '+').replaceAll('_', '/');
|
||||
final padded = normalized + '=' * ((4 - normalized.length % 4) % 4);
|
||||
return base64.decode(padded);
|
||||
}
|
||||
|
||||
bool _looksLikeSettingsSnapshot(Map<String, dynamic> json) {
|
||||
return json.containsKey('appLanguage') ||
|
||||
json.containsKey('gateway') ||
|
||||
@ -718,46 +326,3 @@ class SettingsStore {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _LegacySourceResult {
|
||||
const _LegacySourceResult({
|
||||
required this.sourcePath,
|
||||
this.settings,
|
||||
this.threads,
|
||||
this.locked = false,
|
||||
});
|
||||
|
||||
final String sourcePath;
|
||||
final SettingsSnapshot? settings;
|
||||
final List<AssistantThreadRecord>? threads;
|
||||
final bool locked;
|
||||
}
|
||||
|
||||
class _LegacyStateReadResult {
|
||||
const _LegacyStateReadResult({
|
||||
this.snapshot,
|
||||
this.threads,
|
||||
this.locked = false,
|
||||
});
|
||||
|
||||
final SettingsSnapshot? snapshot;
|
||||
final List<AssistantThreadRecord>? threads;
|
||||
final bool locked;
|
||||
}
|
||||
|
||||
class _AssistantStateSnapshot {
|
||||
const _AssistantStateSnapshot({
|
||||
required this.settings,
|
||||
required this.assistantThreads,
|
||||
});
|
||||
|
||||
final SettingsSnapshot settings;
|
||||
final List<AssistantThreadRecord> assistantThreads;
|
||||
}
|
||||
|
||||
class _LegacyBackupReadResult {
|
||||
const _LegacyBackupReadResult({this.snapshot, this.locked = false});
|
||||
|
||||
final _AssistantStateSnapshot? snapshot;
|
||||
final bool locked;
|
||||
}
|
||||
|
||||
@ -4,10 +4,8 @@ library;
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:sqlite3/sqlite3.dart' as sqlite;
|
||||
import 'package:xworkmate/models/app_models.dart';
|
||||
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
import 'package:xworkmate/runtime/secure_config_store.dart';
|
||||
@ -416,7 +414,7 @@ void main() {
|
||||
);
|
||||
|
||||
test(
|
||||
'SecureConfigStore fails fast and keeps legacy files untouched when sqlite is unavailable',
|
||||
'SecureConfigStore fails fast and keeps stray local-state files untouched when sqlite is unavailable',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
@ -455,7 +453,7 @@ void main() {
|
||||
);
|
||||
|
||||
test(
|
||||
'SecureConfigStore migrates plaintext local state into the new settings store and clears legacy prefs',
|
||||
'SecureConfigStore ignores legacy shared-preferences assistant state and only reads sqlite',
|
||||
() async {
|
||||
final legacySnapshot = SettingsSnapshot.defaults().copyWith(
|
||||
accountUsername: 'legacy-user',
|
||||
@ -507,35 +505,33 @@ void main() {
|
||||
final loadedSnapshot = await store.loadSettingsSnapshot();
|
||||
final loadedThreads = await store.loadAssistantThreadRecords();
|
||||
|
||||
expect(loadedSnapshot.accountUsername, 'legacy-user');
|
||||
expect(loadedSnapshot.assistantLastSessionKey, 'draft:legacy-1');
|
||||
expect(loadedThreads.single.messages.single.text, 'legacy message');
|
||||
expect(
|
||||
loadedSnapshot.accountUsername,
|
||||
SettingsSnapshot.defaults().accountUsername,
|
||||
);
|
||||
expect(loadedSnapshot.assistantLastSessionKey, isEmpty);
|
||||
expect(loadedThreads, isEmpty);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
expect(prefs.getString('xworkmate.settings.snapshot'), isNull);
|
||||
expect(prefs.getString('xworkmate.assistant.threads'), isNull);
|
||||
|
||||
final settingsValue = _readDatabaseValue(
|
||||
databasePath,
|
||||
SettingsStore.settingsKey,
|
||||
expect(
|
||||
prefs.getString('xworkmate.settings.snapshot'),
|
||||
legacySnapshot.toJsonString(),
|
||||
);
|
||||
final threadsValue = _readDatabaseValue(
|
||||
databasePath,
|
||||
SettingsStore.assistantThreadsKey,
|
||||
expect(
|
||||
prefs.getString('xworkmate.assistant.threads'),
|
||||
jsonEncode(
|
||||
legacyRecords.map((item) => item.toJson()).toList(growable: false),
|
||||
),
|
||||
);
|
||||
expect(settingsValue, contains('legacy-user'));
|
||||
expect(threadsValue, contains('legacy message'));
|
||||
expect(settingsValue, isNot(contains(SettingsStore.sealedStateFormat)));
|
||||
expect(threadsValue, isNot(contains(SettingsStore.sealedStateFormat)));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SecureConfigStore recovers sealed legacy local state when the local-state key is available',
|
||||
'SecureConfigStore ignores stray local-state files when sqlite has no assistant state',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-config-store-sealed-recovery-',
|
||||
'xworkmate-config-store-ignore-stray-files-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await tempDirectory.exists()) {
|
||||
@ -543,128 +539,25 @@ void main() {
|
||||
}
|
||||
});
|
||||
final databasePath = '${tempDirectory.path}/settings.sqlite3';
|
||||
final secureStorage = _MapSecureStorageClient();
|
||||
final localStateKey = List<int>.generate(32, (index) => index + 1);
|
||||
final encodedKey = _base64UrlNoPadding(localStateKey);
|
||||
final keyFallbackFile = File('${tempDirectory.path}/local-state-key.txt');
|
||||
await keyFallbackFile.writeAsString(encodedKey, flush: true);
|
||||
|
||||
final snapshot = SettingsSnapshot.defaults().copyWith(
|
||||
accountUsername: 'migrated-user',
|
||||
assistantLastSessionKey: 'draft:migrated-1',
|
||||
);
|
||||
const records = <AssistantThreadRecord>[
|
||||
AssistantThreadRecord(
|
||||
sessionKey: 'draft:migrated-1',
|
||||
title: 'Migrated thread',
|
||||
archived: false,
|
||||
executionTarget: AssistantExecutionTarget.local,
|
||||
messageViewMode: AssistantMessageViewMode.rendered,
|
||||
updatedAtMs: 1700000000000,
|
||||
messages: <GatewayChatMessage>[
|
||||
GatewayChatMessage(
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
text: 'migrated message',
|
||||
timestampMs: 1700000001000,
|
||||
toolCallId: null,
|
||||
toolName: null,
|
||||
stopReason: null,
|
||||
pending: false,
|
||||
error: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
await File('${tempDirectory.path}/settings-snapshot.json').writeAsString(
|
||||
await _sealLocalStateForTest(
|
||||
key: SettingsStore.settingsKey,
|
||||
plaintext: snapshot.toJsonString(),
|
||||
keyBytes: localStateKey,
|
||||
),
|
||||
flush: true,
|
||||
);
|
||||
await File('${tempDirectory.path}/assistant-threads.json').writeAsString(
|
||||
await _sealLocalStateForTest(
|
||||
key: SettingsStore.assistantThreadsKey,
|
||||
plaintext: jsonEncode(
|
||||
records.map((item) => item.toJson()).toList(growable: false),
|
||||
),
|
||||
keyBytes: localStateKey,
|
||||
),
|
||||
flush: true,
|
||||
);
|
||||
await File(
|
||||
'${tempDirectory.path}/settings-snapshot.json',
|
||||
).writeAsString('{"accountUsername":"locked-user"}', flush: true);
|
||||
await File(
|
||||
'${tempDirectory.path}/assistant-threads.json',
|
||||
).writeAsString('[{"sessionKey":"ignored-thread"}]', flush: true);
|
||||
|
||||
final store = SecureConfigStore(
|
||||
databasePathResolver: () async => databasePath,
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
secureStorage: secureStorage,
|
||||
);
|
||||
final loadedSnapshot = await store.loadSettingsSnapshot();
|
||||
final loadedThreads = await store.loadAssistantThreadRecords();
|
||||
|
||||
expect(loadedSnapshot.accountUsername, 'migrated-user');
|
||||
expect(loadedThreads.single.messages.single.text, 'migrated message');
|
||||
expect(store.lastRecoveryReport.status, LegacyRecoveryStatus.migrated);
|
||||
expect(
|
||||
secureStorage._values[SecretStore.legacyLocalStateKey],
|
||||
encodedKey,
|
||||
);
|
||||
expect(await keyFallbackFile.exists(), isFalse);
|
||||
expect(
|
||||
_readDatabaseValue(databasePath, SettingsStore.settingsKey),
|
||||
contains('migrated-user'),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SecureConfigStore reports locked legacy state when sealed settings exist without a recoverable key',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-config-store-locked-legacy-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await tempDirectory.exists()) {
|
||||
await tempDirectory.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
final databasePath = '${tempDirectory.path}/settings.sqlite3';
|
||||
final localStateKey = List<int>.generate(32, (index) => 32 - index);
|
||||
final snapshot = SettingsSnapshot.defaults().copyWith(
|
||||
accountUsername: 'locked-user',
|
||||
);
|
||||
|
||||
await File('${tempDirectory.path}/settings-snapshot.json').writeAsString(
|
||||
await _sealLocalStateForTest(
|
||||
key: SettingsStore.settingsKey,
|
||||
plaintext: snapshot.toJsonString(),
|
||||
keyBytes: localStateKey,
|
||||
),
|
||||
flush: true,
|
||||
);
|
||||
|
||||
final store = SecureConfigStore(
|
||||
databasePathResolver: () async => databasePath,
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
secureStorage: _MapSecureStorageClient(),
|
||||
);
|
||||
final loadedSnapshot = await store.loadSettingsSnapshot();
|
||||
|
||||
expect(
|
||||
loadedSnapshot.accountUsername,
|
||||
SettingsSnapshot.defaults().accountUsername,
|
||||
);
|
||||
expect(
|
||||
store.lastRecoveryReport.status,
|
||||
LegacyRecoveryStatus.lockedLegacyState,
|
||||
);
|
||||
expect(
|
||||
store.lastRecoveryReport.details,
|
||||
contains('could not restore the local-state key'),
|
||||
);
|
||||
expect(loadedThreads, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
@ -1156,24 +1049,6 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
String _readDatabaseValue(String databasePath, String key) {
|
||||
final database = sqlite.sqlite3.open(databasePath);
|
||||
try {
|
||||
final result = database.select(
|
||||
'''
|
||||
SELECT value
|
||||
FROM ${SettingsStore.databaseTableName}
|
||||
WHERE storage_key = ?
|
||||
LIMIT 1
|
||||
''',
|
||||
<Object?>[key],
|
||||
);
|
||||
return result.single['value']! as String;
|
||||
} finally {
|
||||
database.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _MapSecureStorageClient implements SecureStorageClient {
|
||||
final Map<String, String> _values = <String, String>{};
|
||||
|
||||
@ -1192,28 +1067,3 @@ class _MapSecureStorageClient implements SecureStorageClient {
|
||||
_values[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _sealLocalStateForTest({
|
||||
required String key,
|
||||
required String plaintext,
|
||||
required List<int> keyBytes,
|
||||
}) async {
|
||||
const nonce = <int>[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
|
||||
final cipher = AesGcm.with256bits();
|
||||
final secretBox = await cipher.encrypt(
|
||||
utf8.encode(plaintext),
|
||||
secretKey: SecretKey(keyBytes),
|
||||
nonce: nonce,
|
||||
aad: utf8.encode(key),
|
||||
);
|
||||
return jsonEncode(<String, dynamic>{
|
||||
'storageFormat': SettingsStore.sealedStateFormat,
|
||||
'nonce': _base64UrlNoPadding(secretBox.nonce),
|
||||
'cipherText': _base64UrlNoPadding(secretBox.cipherText),
|
||||
'mac': _base64UrlNoPadding(secretBox.mac.bytes),
|
||||
});
|
||||
}
|
||||
|
||||
String _base64UrlNoPadding(List<int> bytes) {
|
||||
return base64Url.encode(bytes).replaceAll('=', '');
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user