Refactor settings persistence and upgrade recovery

This commit is contained in:
Haitao Pan 2026-03-22 16:01:04 +08:00
parent 44469f65e2
commit ee3f9ec80b
9 changed files with 2400 additions and 1823 deletions

View File

@ -157,6 +157,14 @@ class AppController extends ChangeNotifier {
SettingsDetailPage? _settingsDetail;
SettingsNavigationContext? _settingsNavigationContext;
DetailPanelData? _detailPanel;
SettingsSnapshot _settingsDraft = SettingsSnapshot.defaults();
SettingsSnapshot _lastAppliedSettings = SettingsSnapshot.defaults();
final Map<String, String> _draftSecretValues = <String, String>{};
bool _settingsDraftInitialized = false;
bool _pendingSettingsApply = false;
bool _pendingGatewayApply = false;
bool _pendingAiGatewayApply = false;
String _settingsDraftStatusMessage = '';
bool _initializing = true;
String? _bootstrapError;
StreamSubscription<GatewayPushEvent>? _runtimeEventsSubscription;
@ -216,6 +224,14 @@ class AppController extends ChangeNotifier {
GatewayConnectionSnapshot get connection => _runtime.snapshot;
SettingsSnapshot get settings => _settingsController.snapshot;
SettingsSnapshot get settingsDraft =>
_settingsDraftInitialized ? _settingsDraft : settings;
bool get hasSettingsDraftChanges =>
settingsDraft.toJsonString() != settings.toJsonString() ||
_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 => isAiGatewayOnlyMode
? _assistantSessionSummaries()
@ -269,6 +285,12 @@ class AppController extends ChangeNotifier {
bool get isMultiAgentRunPending => _multiAgentRunPending;
bool _desktopPlatformBusy = false;
static const String _draftGatewayTokenKey = 'gateway_token';
static const String _draftGatewayPasswordKey = 'gateway_password';
static const String _draftAiGatewayApiKeyKey = 'ai_gateway_api_key';
static const String _draftVaultTokenKey = 'vault_token';
static const String _draftOllamaApiKeyKey = 'ollama_cloud_api_key';
bool get hasAssistantPendingRun =>
assistantSessionHasPendingRun(currentSessionKey);
@ -1813,6 +1835,111 @@ class AppController extends ChangeNotifier {
return synced;
}
Future<void> saveSettingsDraft(SettingsSnapshot snapshot) async {
if (_disposed) {
return;
}
_settingsDraft = _sanitizeFeatureFlagSettings(
_sanitizeMultiAgentSettings(
_sanitizeOllamaCloudSettings(_sanitizeCodeAgentSettings(snapshot)),
),
);
_settingsDraftInitialized = true;
_settingsDraftStatusMessage = appText(
'草稿已更新,点击顶部保存持久化。',
'Draft updated. Use the top Save button to persist it.',
);
notifyListeners();
}
void saveGatewayTokenDraft(String value) {
_saveSecretDraft(_draftGatewayTokenKey, value);
}
void saveGatewayPasswordDraft(String value) {
_saveSecretDraft(_draftGatewayPasswordKey, value);
}
void saveAiGatewayApiKeyDraft(String value) {
_saveSecretDraft(_draftAiGatewayApiKeyKey, value);
}
void saveVaultTokenDraft(String value) {
_saveSecretDraft(_draftVaultTokenKey, value);
}
void saveOllamaCloudApiKeyDraft(String value) {
_saveSecretDraft(_draftOllamaApiKeyKey, value);
}
Future<void> persistSettingsDraft() async {
if (_disposed) {
return;
}
if (!hasSettingsDraftChanges) {
_settingsDraftStatusMessage = appText(
'没有需要保存的更改。',
'There are no changes to save.',
);
notifyListeners();
return;
}
final nextSettings = settingsDraft;
_markPendingApplyDomains(settings, nextSettings);
await _persistDraftSecrets();
if (nextSettings.toJsonString() != settings.toJsonString()) {
await _persistSettingsSnapshot(nextSettings);
}
_settingsDraft = settings;
_settingsDraftInitialized = true;
_pendingSettingsApply = true;
_settingsDraftStatusMessage = appText(
'已保存设置,等待应用。',
'Settings saved. Apply to activate runtime changes.',
);
notifyListeners();
}
Future<void> applySettingsDraft() async {
if (_disposed) {
return;
}
if (hasSettingsDraftChanges) {
await persistSettingsDraft();
}
if (!_pendingSettingsApply) {
_settingsDraftStatusMessage = appText(
'没有需要应用的更改。',
'There are no saved changes to apply.',
);
notifyListeners();
return;
}
final currentSettings = settings;
await _applyPersistedSettingsSideEffects(
previous: _lastAppliedSettings,
current: currentSettings,
refreshAfterSave: true,
);
if (_pendingGatewayApply) {
await _applyPersistedGatewaySettings(currentSettings);
}
if (_pendingAiGatewayApply) {
await _applyPersistedAiGatewaySettings(currentSettings);
}
_lastAppliedSettings = settings;
_pendingSettingsApply = false;
_pendingGatewayApply = false;
_pendingAiGatewayApply = false;
_settingsDraft = settings;
_settingsDraftInitialized = true;
_settingsDraftStatusMessage = appText(
'已应用全部设置。',
'All saved settings have been applied.',
);
notifyListeners();
}
Future<void> saveSettings(
SettingsSnapshot snapshot, {
bool refreshAfterSave = true,
@ -1820,45 +1947,24 @@ class AppController extends ChangeNotifier {
if (_disposed) {
return;
}
final current = settings;
final sanitized = _sanitizeFeatureFlagSettings(
_sanitizeMultiAgentSettings(
_sanitizeOllamaCloudSettings(_sanitizeCodeAgentSettings(snapshot)),
),
final previous = settings;
await _persistSettingsSnapshot(snapshot);
if (_disposed) {
return;
}
await _applyPersistedSettingsSideEffects(
previous: previous,
current: settings,
refreshAfterSave: refreshAfterSave,
);
setActiveAppLanguage(sanitized.appLanguage);
await _settingsController.saveSnapshot(sanitized);
if (_disposed) {
return;
}
_multiAgentOrchestrator.updateConfig(sanitized.multiAgent);
_agentsController.restoreSelection(sanitized.gateway.selectedAgentId);
_modelsController.restoreFromSettings(sanitized.aiGateway);
if (_disposed) {
return;
}
if (current.codexCliPath != sanitized.codexCliPath ||
current.codeAgentRuntimeMode != sanitized.codeAgentRuntimeMode) {
_registerCodexExternalProvider(codexPath: sanitized.codexCliPath);
await _refreshCodexCliAvailability();
if (_disposed) {
return;
}
}
if (current.linuxDesktop.toJson().toString() !=
sanitized.linuxDesktop.toJson().toString() ||
current.launchAtLogin != sanitized.launchAtLogin) {
await _desktopPlatformService.syncConfig(sanitized.linuxDesktop);
await _desktopPlatformService.setLaunchAtLogin(sanitized.launchAtLogin);
if (_disposed) {
return;
}
}
if (refreshAfterSave) {
_recomputeTasks();
}
unawaited(refreshMultiAgentMounts(sync: sanitized.multiAgent.autoSync));
notifyListeners();
_lastAppliedSettings = settings;
_settingsDraft = settings;
_settingsDraftInitialized = true;
_pendingSettingsApply = false;
_pendingGatewayApply = false;
_pendingAiGatewayApply = false;
_draftSecretValues.clear();
_settingsDraftStatusMessage = '';
}
Future<void> clearAssistantLocalState() async {
@ -2169,6 +2275,15 @@ class AppController extends ChangeNotifier {
}
}
await refreshMultiAgentMounts(sync: settings.multiAgent.autoSync);
_settingsDraft = settings;
_lastAppliedSettings = settings;
_settingsDraftInitialized = true;
_settingsDraftStatusMessage = legacyRecoveryReport.hasIssue
? appText(
'检测到旧版本配置,但当前版本无法解锁旧加密状态。',
'Detected legacy settings, but this build could not unlock the old encrypted state.',
)
: '';
} catch (error) {
if (_disposed) {
return;
@ -2210,6 +2325,142 @@ class AppController extends ChangeNotifier {
_recomputeTasks();
}
void _saveSecretDraft(String key, String value) {
final trimmed = value.trim();
if (trimmed.isEmpty) {
_draftSecretValues.remove(key);
} else {
_draftSecretValues[key] = trimmed;
}
_settingsDraftStatusMessage = appText(
'草稿已更新,点击顶部保存持久化。',
'Draft updated. Use the top Save button to persist it.',
);
notifyListeners();
}
void _markPendingApplyDomains(
SettingsSnapshot previous,
SettingsSnapshot next,
) {
final gatewayChanged =
previous.gateway.toJson().toString() != next.gateway.toJson().toString() ||
previous.assistantExecutionTarget != next.assistantExecutionTarget ||
_draftSecretValues.containsKey(_draftGatewayTokenKey) ||
_draftSecretValues.containsKey(_draftGatewayPasswordKey);
final aiGatewayChanged =
previous.aiGateway.toJson().toString() !=
next.aiGateway.toJson().toString() ||
previous.defaultModel != next.defaultModel ||
_draftSecretValues.containsKey(_draftAiGatewayApiKeyKey);
_pendingGatewayApply = _pendingGatewayApply || gatewayChanged;
_pendingAiGatewayApply = _pendingAiGatewayApply || aiGatewayChanged;
}
Future<void> _persistDraftSecrets() async {
final gatewayToken = _draftSecretValues[_draftGatewayTokenKey];
final gatewayPassword = _draftSecretValues[_draftGatewayPasswordKey];
if ((gatewayToken ?? '').isNotEmpty || (gatewayPassword ?? '').isNotEmpty) {
await _settingsController.saveGatewaySecrets(
token: gatewayToken ?? '',
password: gatewayPassword ?? '',
);
}
final aiGatewayApiKey = _draftSecretValues[_draftAiGatewayApiKeyKey];
if ((aiGatewayApiKey ?? '').isNotEmpty) {
await _settingsController.saveAiGatewayApiKey(aiGatewayApiKey!);
}
final vaultToken = _draftSecretValues[_draftVaultTokenKey];
if ((vaultToken ?? '').isNotEmpty) {
await _settingsController.saveVaultToken(vaultToken!);
}
final ollamaApiKey = _draftSecretValues[_draftOllamaApiKeyKey];
if ((ollamaApiKey ?? '').isNotEmpty) {
await _settingsController.saveOllamaCloudApiKey(ollamaApiKey!);
}
_draftSecretValues.clear();
}
Future<void> _persistSettingsSnapshot(SettingsSnapshot snapshot) async {
final sanitized = _sanitizeFeatureFlagSettings(
_sanitizeMultiAgentSettings(
_sanitizeOllamaCloudSettings(_sanitizeCodeAgentSettings(snapshot)),
),
);
await _settingsController.saveSnapshot(sanitized);
_settingsDraft = sanitized;
_settingsDraftInitialized = true;
}
Future<void> _applyPersistedSettingsSideEffects({
required SettingsSnapshot previous,
required SettingsSnapshot current,
required bool refreshAfterSave,
}) async {
setActiveAppLanguage(current.appLanguage);
_multiAgentOrchestrator.updateConfig(current.multiAgent);
_agentsController.restoreSelection(current.gateway.selectedAgentId);
_modelsController.restoreFromSettings(current.aiGateway);
if (_disposed) {
return;
}
if (previous.codexCliPath != current.codexCliPath ||
previous.codeAgentRuntimeMode != current.codeAgentRuntimeMode) {
_registerCodexExternalProvider(codexPath: current.codexCliPath);
await _refreshCodexCliAvailability();
if (_disposed) {
return;
}
}
if (previous.linuxDesktop.toJson().toString() !=
current.linuxDesktop.toJson().toString() ||
previous.launchAtLogin != current.launchAtLogin) {
await _desktopPlatformService.syncConfig(current.linuxDesktop);
await _desktopPlatformService.setLaunchAtLogin(current.launchAtLogin);
if (_disposed) {
return;
}
}
if (refreshAfterSave) {
_recomputeTasks();
}
unawaited(refreshMultiAgentMounts(sync: current.multiAgent.autoSync));
notifyListeners();
}
Future<void> _applyPersistedGatewaySettings(SettingsSnapshot snapshot) async {
if (snapshot.assistantExecutionTarget == AssistantExecutionTarget.aiGatewayOnly) {
if (_runtime.isConnected) {
try {
await disconnectGateway();
} catch (_) {
// Keep saved settings even when runtime teardown is noisy.
}
}
return;
}
try {
await _connectProfile(snapshot.gateway);
} catch (_) {
// Save/apply should keep persisted config even if the immediate
// connection attempt fails.
}
}
Future<void> _applyPersistedAiGatewaySettings(
SettingsSnapshot snapshot,
) async {
final apiKey = await _settingsController.loadAiGatewayApiKey();
if (snapshot.aiGateway.baseUrl.trim().isEmpty || apiKey.trim().isEmpty) {
return;
}
try {
await syncAiGatewayCatalog(snapshot.aiGateway, apiKeyOverride: apiKey);
} catch (_) {
// Keep the saved draft applied even if model sync fails immediately.
}
}
Future<void> _ensureActiveAssistantThread() async {
if (!isAiGatewayOnlyMode ||
!isAssistantTaskArchived(_sessionsController.currentSessionKey)) {

View File

@ -4,6 +4,7 @@ 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';
@ -47,9 +48,14 @@ class AppController extends ChangeNotifier {
late final StreamSubscription<GatewayPushEvent> _relayEventsSubscription;
SettingsSnapshot _settings = SettingsSnapshot.defaults();
SettingsSnapshot _settingsDraft = SettingsSnapshot.defaults();
ThemeMode _themeMode = ThemeMode.light;
WorkspaceDestination _destination = WorkspaceDestination.assistant;
SettingsTab _settingsTab = SettingsTab.general;
bool _settingsDraftInitialized = false;
bool _pendingSettingsApply = false;
String _settingsDraftStatusMessage = '';
final Map<String, String> _draftSecretValues = <String, String>{};
bool _initializing = true;
String? _bootstrapError;
bool _relayBusy = false;
@ -73,6 +79,14 @@ class AppController extends ChangeNotifier {
bool get initializing => _initializing;
String? get bootstrapError => _bootstrapError;
SettingsSnapshot get settings => _settings;
SettingsSnapshot get settingsDraft =>
_settingsDraftInitialized ? _settingsDraft : _settings;
bool get hasSettingsDraftChanges =>
settingsDraft.toJsonString() != _settings.toJsonString() ||
_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;
@ -110,6 +124,10 @@ class AppController extends ChangeNotifier {
String _relayPasswordCache = '';
String _aiGatewayApiKeyCache = '';
static const String _draftAiGatewayApiKeyKey = 'ai_gateway_api_key';
static const String _draftVaultTokenKey = 'vault_token';
static const String _draftOllamaApiKeyKey = 'ollama_cloud_api_key';
UiFeatureAccess featuresFor(UiFeaturePlatform platform) {
return _uiFeatureManifest.forPlatform(platform);
}
@ -312,6 +330,8 @@ class AppController extends ChangeNotifier {
_threadRecords[record.sessionKey] = record;
}
_currentSessionKey = conversations.first.sessionKey;
_settingsDraft = _settings;
_settingsDraftInitialized = true;
} catch (error) {
_bootstrapError = '$error';
} finally {
@ -384,6 +404,72 @@ class AppController extends ChangeNotifier {
notifyListeners();
}
Future<void> saveSettingsDraft(SettingsSnapshot snapshot) async {
_settingsDraft = snapshot;
_settingsDraftInitialized = true;
_settingsDraftStatusMessage = appText(
'草稿已更新,点击顶部保存持久化。',
'Draft updated. Use the top Save button to persist it.',
);
notifyListeners();
}
void saveAiGatewayApiKeyDraft(String value) {
_saveSecretDraft(_draftAiGatewayApiKeyKey, value);
}
void saveVaultTokenDraft(String value) {
_saveSecretDraft(_draftVaultTokenKey, value);
}
void saveOllamaCloudApiKeyDraft(String value) {
_saveSecretDraft(_draftOllamaApiKeyKey, value);
}
Future<void> persistSettingsDraft() async {
if (!hasSettingsDraftChanges) {
_settingsDraftStatusMessage = appText(
'没有需要保存的更改。',
'There are no changes to save.',
);
notifyListeners();
return;
}
_settings = settingsDraft;
await _persistDraftSecrets();
await _persistSettings();
_settingsDraft = _settings;
_settingsDraftInitialized = true;
_pendingSettingsApply = true;
_settingsDraftStatusMessage = appText(
'已保存设置,等待应用。',
'Settings saved. Apply to activate runtime changes.',
);
notifyListeners();
}
Future<void> applySettingsDraft() async {
if (hasSettingsDraftChanges) {
await persistSettingsDraft();
}
if (!_pendingSettingsApply) {
_settingsDraftStatusMessage = appText(
'没有需要应用的更改。',
'There are no saved changes to apply.',
);
notifyListeners();
return;
}
_settingsDraft = _settings;
_settingsDraftInitialized = true;
_pendingSettingsApply = false;
_settingsDraftStatusMessage = appText(
'已应用全部设置。',
'All saved settings have been applied.',
);
notifyListeners();
}
Future<void> toggleAppLanguage() async {
final next = _settings.appLanguage == AppLanguage.zh
? AppLanguage.en
@ -906,6 +992,29 @@ class AppController extends ChangeNotifier {
await _store.saveSettingsSnapshot(_settings);
}
void _saveSecretDraft(String key, String value) {
final trimmed = value.trim();
if (trimmed.isEmpty) {
_draftSecretValues.remove(key);
} else {
_draftSecretValues[key] = trimmed;
}
_settingsDraftStatusMessage = appText(
'草稿已更新,点击顶部保存持久化。',
'Draft updated. Use the top Save button to persist it.',
);
notifyListeners();
}
Future<void> _persistDraftSecrets() async {
final aiGatewayApiKey = _draftSecretValues[_draftAiGatewayApiKeyKey];
if ((aiGatewayApiKey ?? '').isNotEmpty) {
_aiGatewayApiKeyCache = aiGatewayApiKey!;
await _store.saveAiGatewayApiKey(_aiGatewayApiKeyCache);
}
_draftSecretValues.clear();
}
Future<void> _persistThreads() async {
final records = _threadRecords.values.toList(growable: false);
await _browserSessionRepository.saveThreadRecords(records);

View File

@ -49,7 +49,6 @@ class _SettingsPageState extends State<SettingsPage> {
late final TextEditingController _ollamaApiKeyController;
late final TextEditingController _runtimeLogFilterController;
bool _aiGatewayTesting = false;
bool _aiGatewaySyncing = false;
String _aiGatewayTestState = 'idle';
String _aiGatewayTestMessage = '';
String _aiGatewayTestEndpoint = '';
@ -115,7 +114,7 @@ class _SettingsPageState extends State<SettingsPage> {
_tab = uiFeatures.sanitizeSettingsTab(controller.settingsTab);
_detail = controller.settingsDetail;
_navigationContext = controller.settingsNavigationContext;
final settings = controller.settings;
final settings = controller.settingsDraft;
final showingDetail = _detail != null;
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(32, 32, 32, 8),
@ -162,6 +161,8 @@ class _SettingsPageState extends State<SettingsPage> {
),
),
const SizedBox(height: 24),
_buildGlobalApplyBar(context, controller),
const SizedBox(height: 16),
if (!showingDetail) ...[
SectionTabs(
items: availableTabs.map((item) => item.label).toList(),
@ -320,6 +321,80 @@ class _SettingsPageState extends State<SettingsPage> {
);
}
Widget _buildGlobalApplyBar(BuildContext context, AppController controller) {
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;
return SurfaceCard(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('设置提交流程', 'Settings Submission'),
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 6),
Text(
recoveryIssue
? message
: hasDraft
? appText(
'当前存在未保存草稿。保存会持久化配置,但不会触发连接或模型同步。',
'There are unsaved drafts. Save persists settings without connecting or syncing models.',
)
: hasPendingApply
? appText(
'当前存在已保存但未应用的更改。点击应用会触发连接和模型同步。',
'There are saved changes waiting to be applied. Apply will trigger connection and model sync.',
)
: (message.isEmpty
? appText(
'当前没有待提交更改。',
'There are no pending settings changes.',
)
: message),
style: theme.textTheme.bodyMedium,
),
],
),
),
const SizedBox(width: 16),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
OutlinedButton(
key: const ValueKey('settings-global-save-button'),
onPressed: (!hasDraft && !recoveryIssue)
? null
: () => _handleTopLevelSave(controller),
child: Text(appText('保存', 'Save')),
),
FilledButton.tonal(
key: const ValueKey('settings-global-apply-button'),
onPressed: (!hasDraft && !hasPendingApply && !recoveryIssue)
? null
: () => _handleTopLevelApply(controller),
child: Text(appText('应用', 'Apply')),
),
],
),
],
),
);
}
List<Widget> _buildGeneral(
BuildContext context,
AppController controller,
@ -467,20 +542,29 @@ class _SettingsPageState extends State<SettingsPage> {
_SwitchRow(
label: appText('开机启动', 'Launch at login'),
value: settings.launchAtLogin,
onChanged: (value) => controller.setLaunchAtLogin(value),
onChanged: (value) => _saveSettings(
controller,
settings.copyWith(launchAtLogin: value),
),
),
_SwitchRow(
label: appText('托盘菜单', 'Tray menu'),
value: config.trayEnabled,
onChanged: (value) => controller.saveLinuxDesktopConfig(
config.copyWith(trayEnabled: value),
onChanged: (value) => _saveSettings(
controller,
settings.copyWith(
linuxDesktop: config.copyWith(trayEnabled: value),
),
),
),
_EditableField(
label: appText('隧道连接名称', 'Tunnel Connection Name'),
value: config.vpnConnectionName,
onSubmitted: (value) => controller.saveLinuxDesktopConfig(
config.copyWith(vpnConnectionName: value.trim()),
onSubmitted: (value) => _saveSettings(
controller,
settings.copyWith(
linuxDesktop: config.copyWith(vpnConnectionName: value.trim()),
),
),
),
Row(
@ -489,8 +573,11 @@ class _SettingsPageState extends State<SettingsPage> {
child: _EditableField(
label: appText('代理主机', 'Proxy Host'),
value: config.proxyHost,
onSubmitted: (value) => controller.saveLinuxDesktopConfig(
config.copyWith(proxyHost: value.trim()),
onSubmitted: (value) => _saveSettings(
controller,
settings.copyWith(
linuxDesktop: config.copyWith(proxyHost: value.trim()),
),
),
),
),
@ -504,8 +591,11 @@ class _SettingsPageState extends State<SettingsPage> {
if (parsed == null || parsed <= 0) {
return;
}
controller.saveLinuxDesktopConfig(
config.copyWith(proxyPort: parsed),
_saveSettings(
controller,
settings.copyWith(
linuxDesktop: config.copyWith(proxyPort: parsed),
),
);
},
),
@ -736,27 +826,22 @@ class _SettingsPageState extends State<SettingsPage> {
onStateChanged: (value) =>
setState(() => _ollamaApiKeyState = value),
loadValue: controller.settingsController.loadOllamaCloudApiKey,
onSubmitted: controller.settingsController.saveOllamaCloudApiKey,
onSubmitted: (value) async =>
controller.saveOllamaCloudApiKeyDraft(value),
storedHelperText: appText(
'已安全保存,默认以 **** 显示,点击查看后读取真实值。',
'Stored securely. Shows as **** until you reveal it.',
),
emptyHelperText: appText(
'输入后会安全保存到本机密钥存储。',
'Saving writes to secure local key storage.',
'输入后先进入草稿;顶部保存后才写入安全存储。',
'Values stage into draft first and only persist to secure storage after Save.',
),
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton(
onPressed: () async {
await _persistOllamaApiKeyIfNeeded(
controller,
hasStoredValue: hasStoredOllamaApiKey,
);
await controller.testOllamaConnection(cloud: true);
},
onPressed: () => controller.testOllamaConnection(cloud: true),
child: Text(
'${appText('测试云端', 'Test Cloud')} · ${controller.settingsController.ollamaStatus}',
),
@ -928,27 +1013,22 @@ class _SettingsPageState extends State<SettingsPage> {
onStateChanged: (value) =>
setState(() => _vaultTokenState = value),
loadValue: controller.settingsController.loadVaultToken,
onSubmitted: controller.settingsController.saveVaultToken,
onSubmitted: (value) async =>
controller.saveVaultTokenDraft(value),
storedHelperText: appText(
'已安全保存,默认以 **** 显示,点击查看后读取真实值。',
'Stored securely. Shows as **** until you reveal it.',
),
emptyHelperText: appText(
'输入后会安全保存到本机密钥存储。',
'Saving writes to secure local key storage.',
'输入后先进入草稿;顶部保存后才写入安全存储。',
'Values stage into draft first and only persist to secure storage after Save.',
),
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton(
onPressed: () async {
await _persistVaultTokenIfNeeded(
controller,
hasStoredValue: hasStoredVaultToken,
);
await controller.testVaultConnection();
},
onPressed: () => controller.testVaultConnection(),
child: Text(
'${appText('测试 Vault', 'Test Vault')} · ${controller.settingsController.vaultStatus}',
),
@ -973,6 +1053,9 @@ class _SettingsPageState extends State<SettingsPage> {
decoration: InputDecoration(
labelText: appText('配置名称', 'Profile Name'),
),
onChanged: (_) => unawaited(
_saveAiGatewayDraft(controller, settings).catchError((_) {}),
),
onSubmitted: (_) => _saveAiGatewayDraft(controller, settings),
),
const SizedBox(height: 14),
@ -982,6 +1065,9 @@ class _SettingsPageState extends State<SettingsPage> {
decoration: InputDecoration(
labelText: appText('Gateway URL', 'Gateway URL'),
),
onChanged: (_) => unawaited(
_saveAiGatewayDraft(controller, settings).catchError((_) {}),
),
onSubmitted: (_) => _saveAiGatewayDraft(controller, settings),
),
const SizedBox(height: 14),
@ -991,6 +1077,9 @@ class _SettingsPageState extends State<SettingsPage> {
decoration: InputDecoration(
labelText: appText('API Key 引用', 'API Key Ref'),
),
onChanged: (_) => unawaited(
_saveAiGatewayDraft(controller, settings).catchError((_) {}),
),
onSubmitted: (_) => _saveAiGatewayDraft(controller, settings),
),
_buildSecureField(
@ -1003,14 +1092,15 @@ class _SettingsPageState extends State<SettingsPage> {
onStateChanged: (value) =>
setState(() => _aiGatewayApiKeyState = value),
loadValue: controller.settingsController.loadAiGatewayApiKey,
onSubmitted: controller.settingsController.saveAiGatewayApiKey,
onSubmitted: (value) async =>
controller.saveAiGatewayApiKeyDraft(value),
storedHelperText: appText(
'已安全保存,默认以 **** 显示;可直接测试或保存/应用,也可点击查看',
'Stored securely. Test or save/apply directly, or reveal it on demand.',
'已安全保存,默认以 **** 显示;可直接测试,也可保存草稿后再统一提交',
'Stored securely. Test directly or save to draft before the global submit.',
),
emptyHelperText: appText(
'输入后点击测试连接或保存/应用。',
'Test or save/apply to persist securely.',
'输入后可测试连接,或先保存到草稿,顶部再统一保存/应用。',
'Test the connection now, or stage it for the top-level Save / Apply flow.',
),
),
const SizedBox(height: 12),
@ -1020,7 +1110,7 @@ class _SettingsPageState extends State<SettingsPage> {
children: [
OutlinedButton(
key: const ValueKey('ai-gateway-test-button'),
onPressed: _aiGatewayTesting || _aiGatewaySyncing
onPressed: _aiGatewayTesting
? null
: () => _testAiGatewayConnection(controller, settings),
child: Text(
@ -1030,15 +1120,11 @@ class _SettingsPageState extends State<SettingsPage> {
),
),
FilledButton.tonal(
key: const ValueKey('ai-gateway-apply-button'),
onPressed: _aiGatewayTesting || _aiGatewaySyncing
key: const ValueKey('ai-gateway-save-draft-button'),
onPressed: _aiGatewayTesting
? null
: () => _applyAiGatewaySettings(controller, settings),
child: Text(
_aiGatewaySyncing
? appText('应用中...', 'Applying...')
: appText('保存/应用', 'Save / Apply'),
),
: () => _saveAiGatewayDraft(controller, settings),
child: Text(appText('保存草稿', 'Save Draft')),
),
],
),
@ -2077,14 +2163,87 @@ class _SettingsPageState extends State<SettingsPage> {
AppController controller,
SettingsSnapshot snapshot,
) {
return controller.saveSettings(snapshot);
return controller.saveSettingsDraft(snapshot);
}
Future<void> _handleTopLevelSave(AppController controller) async {
await _captureVisibleSecretDrafts(controller);
await controller.persistSettingsDraft();
if (!mounted) {
return;
}
setState(() {
_resetSecureFieldUiAfterPersist(controller);
});
}
Future<void> _handleTopLevelApply(AppController controller) async {
await _captureVisibleSecretDrafts(controller);
await controller.applySettingsDraft();
if (!mounted) {
return;
}
setState(() {
_resetSecureFieldUiAfterPersist(controller);
});
}
Future<void> _captureVisibleSecretDrafts(AppController controller) async {
final aiGatewayApiKey = _secretOverride(
_aiGatewayApiKeyController,
_aiGatewayApiKeyState,
);
if (aiGatewayApiKey.isNotEmpty) {
controller.saveAiGatewayApiKeyDraft(aiGatewayApiKey);
}
final vaultToken = _secretOverride(_vaultTokenController, _vaultTokenState);
if (vaultToken.isNotEmpty) {
controller.saveVaultTokenDraft(vaultToken);
}
final ollamaApiKey = _secretOverride(
_ollamaApiKeyController,
_ollamaApiKeyState,
);
if (ollamaApiKey.isNotEmpty) {
controller.saveOllamaCloudApiKeyDraft(ollamaApiKey);
}
}
void _resetSecureFieldUiAfterPersist(AppController controller) {
final hasStoredAiGatewayApiKey =
controller.settingsController.secureRefs['ai_gateway_api_key'] != null;
final hasStoredVaultToken =
controller.settingsController.secureRefs['vault_token'] != null;
final hasStoredOllamaApiKey =
controller.settingsController.secureRefs['ollama_cloud_api_key'] !=
null;
_aiGatewayApiKeyState = const _SecretFieldUiState();
_vaultTokenState = const _SecretFieldUiState();
_ollamaApiKeyState = const _SecretFieldUiState();
_primeSecureFieldController(
_aiGatewayApiKeyController,
hasStoredValue: hasStoredAiGatewayApiKey,
fieldState: _aiGatewayApiKeyState,
);
_primeSecureFieldController(
_vaultTokenController,
hasStoredValue: hasStoredVaultToken,
fieldState: _vaultTokenState,
);
_primeSecureFieldController(
_ollamaApiKeyController,
hasStoredValue: hasStoredOllamaApiKey,
fieldState: _ollamaApiKeyState,
);
}
Future<void> _saveMultiAgentConfig(
AppController controller,
MultiAgentConfig config,
) {
return controller.saveMultiAgentConfig(config);
return controller.saveSettingsDraft(
controller.settingsDraft.copyWith(multiAgent: config),
);
}
AiGatewayProfile _buildAiGatewayDraft(SettingsSnapshot settings) {
@ -2117,15 +2276,7 @@ class _SettingsPageState extends State<SettingsPage> {
SettingsSnapshot settings,
) async {
final draft = _buildAiGatewayDraft(settings);
final hasStoredAiGatewayApiKey =
controller.settingsController.secureRefs['ai_gateway_api_key'] != null;
await _saveSettings(controller, settings.copyWith(aiGateway: draft));
unawaited(
_persistAiGatewayApiKeyIfNeeded(
controller,
hasStoredValue: hasStoredAiGatewayApiKey,
).catchError((_) {}),
);
if (!mounted) {
return;
}
@ -2139,68 +2290,6 @@ class _SettingsPageState extends State<SettingsPage> {
});
}
Future<void> _applyAiGatewaySettings(
AppController controller,
SettingsSnapshot settings,
) async {
final messenger = ScaffoldMessenger.of(context);
final draft = _buildAiGatewayDraft(settings);
final apiKey = _secretOverride(
_aiGatewayApiKeyController,
_aiGatewayApiKeyState,
);
final hasStoredAiGatewayApiKey =
controller.settingsController.secureRefs['ai_gateway_api_key'] != null;
setState(() => _aiGatewaySyncing = true);
try {
await _saveSettings(controller, settings.copyWith(aiGateway: draft));
await _persistAiGatewayApiKeyIfNeeded(
controller,
hasStoredValue: hasStoredAiGatewayApiKey,
);
if (!mounted) {
return;
}
_aiGatewayNameSyncedValue = draft.name;
_aiGatewayUrlSyncedValue = draft.baseUrl;
_aiGatewayApiKeyRefSyncedValue = draft.apiKeyRef;
if (_aiGatewayTestState != 'ready') {
setState(() {
_aiGatewayTestState = draft.syncState;
_aiGatewayTestMessage = '';
_aiGatewayTestEndpoint = '';
});
messenger.showSnackBar(
SnackBar(
content: Text(appText('AI Gateway 已保存', 'AI Gateway saved')),
),
);
return;
}
final result = await controller.syncAiGatewayCatalog(
draft,
apiKeyOverride: apiKey,
);
if (!mounted) {
return;
}
setState(() {
_aiGatewayTestState = result.syncState;
_aiGatewayTestMessage = result.syncState == 'ready'
? 'Catalog synced · ${result.availableModels.length} model(s) ready'
: result.syncMessage;
_aiGatewayTestEndpoint = result.syncState == 'ready'
? _previewAiGatewayEndpoint(draft.baseUrl)
: '';
});
messenger.showSnackBar(SnackBar(content: Text(result.syncMessage)));
} finally {
if (mounted) {
setState(() => _aiGatewaySyncing = false);
}
}
}
Future<void> _testAiGatewayConnection(
AppController controller,
SettingsSnapshot settings,
@ -2241,30 +2330,6 @@ class _SettingsPageState extends State<SettingsPage> {
.toList(growable: false);
}
String _previewAiGatewayEndpoint(String rawUrl) {
final trimmed = rawUrl.trim();
if (trimmed.isEmpty) {
return '';
}
final candidate = trimmed.contains('://') ? trimmed : 'https://$trimmed';
final uri = Uri.tryParse(candidate);
if (uri == null || uri.host.trim().isEmpty) {
return '';
}
final pathSegments = uri.pathSegments
.where((item) => item.isNotEmpty)
.toList(growable: true);
if (pathSegments.isEmpty) {
pathSegments.add('v1');
}
if (pathSegments.last != 'models') {
pathSegments.add('models');
}
return uri
.replace(pathSegments: pathSegments, query: null, fragment: null)
.toString();
}
Widget _buildSecureField({
Key? fieldKey,
required TextEditingController controller,
@ -2407,45 +2472,6 @@ class _SettingsPageState extends State<SettingsPage> {
onStateChanged(const _SecretFieldUiState());
}
Future<void> _persistAiGatewayApiKeyIfNeeded(
AppController controller, {
required bool hasStoredValue,
}) {
return _persistSecureFieldIfNeeded(
controller: _aiGatewayApiKeyController,
hasStoredValue: hasStoredValue,
fieldState: _aiGatewayApiKeyState,
onStateChanged: (value) => setState(() => _aiGatewayApiKeyState = value),
onSubmitted: controller.settingsController.saveAiGatewayApiKey,
);
}
Future<void> _persistVaultTokenIfNeeded(
AppController controller, {
required bool hasStoredValue,
}) {
return _persistSecureFieldIfNeeded(
controller: _vaultTokenController,
hasStoredValue: hasStoredValue,
fieldState: _vaultTokenState,
onStateChanged: (value) => setState(() => _vaultTokenState = value),
onSubmitted: controller.settingsController.saveVaultToken,
);
}
Future<void> _persistOllamaApiKeyIfNeeded(
AppController controller, {
required bool hasStoredValue,
}) {
return _persistSecureFieldIfNeeded(
controller: _ollamaApiKeyController,
hasStoredValue: hasStoredValue,
fieldState: _ollamaApiKeyState,
onStateChanged: (value) => setState(() => _ollamaApiKeyState = value),
onSubmitted: controller.settingsController.saveOllamaCloudApiKey,
);
}
void _primeSecureFieldController(
TextEditingController controller, {
required bool hasStoredValue,
@ -3111,7 +3137,7 @@ class _SettingsPageState extends State<SettingsPage> {
}
}
class _EditableField extends StatelessWidget {
class _EditableField extends StatefulWidget {
const _EditableField({
required this.label,
required this.value,
@ -3122,15 +3148,48 @@ class _EditableField extends StatelessWidget {
final String value;
final ValueChanged<String> onSubmitted;
@override
State<_EditableField> createState() => _EditableFieldState();
}
class _EditableFieldState extends State<_EditableField> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.value);
}
@override
void didUpdateWidget(covariant _EditableField oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.value == _controller.text) {
return;
}
_controller.value = _controller.value.copyWith(
text: widget.value,
selection: TextSelection.collapsed(offset: widget.value.length),
composing: TextRange.empty,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: TextFormField(
key: ValueKey('$label:$value'),
initialValue: value,
decoration: InputDecoration(labelText: label),
onFieldSubmitted: onSubmitted,
key: ValueKey('${widget.label}:${widget.value}'),
controller: _controller,
decoration: InputDecoration(labelText: widget.label),
onChanged: widget.onSubmitted,
onFieldSubmitted: widget.onSubmitted,
),
);
}

View File

@ -0,0 +1,58 @@
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? ?? '',
);
}
}

View File

@ -0,0 +1,537 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:path_provider/path_provider.dart';
import 'runtime_models.dart';
abstract class SecureStorageClient {
Future<String?> read({required String key});
Future<void> write({required String key, required String value});
Future<void> delete({required String key});
}
class FlutterSecureStorageClient implements SecureStorageClient {
const FlutterSecureStorageClient(this._storage);
final FlutterSecureStorage _storage;
@override
Future<String?> read({required String key}) {
return _storage.read(key: key);
}
@override
Future<void> write({required String key, required String value}) {
return _storage.write(key: key, value: value);
}
@override
Future<void> delete({required String key}) {
return _storage.delete(key: key);
}
}
class FileSecureStorageClient implements SecureStorageClient {
FileSecureStorageClient(this._directoryResolver);
final Future<Directory?> Function() _directoryResolver;
@override
Future<void> delete({required String key}) async {
final file = await _fileForKey(key);
if (file == null || !await file.exists()) {
return;
}
await file.delete();
}
@override
Future<String?> read({required String key}) async {
final file = await _fileForKey(key);
if (file == null || !await file.exists()) {
return null;
}
final value = (await file.readAsString()).trim();
return value.isEmpty ? null : value;
}
@override
Future<void> write({required String key, required String value}) async {
final file = await _fileForKey(key);
if (file == null) {
throw StateError('Secure storage directory unavailable for $key');
}
await file.writeAsString(value, flush: true);
}
Future<File?> _fileForKey(String key) async {
final directory = await _directoryResolver();
if (directory == null) {
return null;
}
final secureDirectory = Directory('${directory.path}/secure-storage');
if (!await secureDirectory.exists()) {
await secureDirectory.create(recursive: true);
}
final safeKey = base64Url.encode(utf8.encode(key)).replaceAll('=', '');
return File('${secureDirectory.path}/$safeKey.txt');
}
}
class MemorySecureStorageClient 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;
}
}
class SecretStore {
SecretStore({
Future<String?> Function()? fallbackDirectoryPathResolver,
Future<String?> Function()? databasePathResolver,
SecureStorageClient? secureStorage,
bool enableSecureStorage = true,
}) : _fallbackDirectoryPathResolver = fallbackDirectoryPathResolver,
_databasePathResolver = databasePathResolver,
_secureStorageOverride = secureStorage,
_enableSecureStorage = enableSecureStorage;
static const Duration _secureStorageTimeout = Duration(seconds: 5);
static const String legacyLocalStateKey = 'xworkmate.local_state.key';
static const String _gatewayTokenKey = 'xworkmate.gateway.token';
static const String _gatewayPasswordKey = 'xworkmate.gateway.password';
static const String _gatewayDeviceIdKey = 'xworkmate.gateway.device.id';
static const String _gatewayDevicePublicKeyKey =
'xworkmate.gateway.device.public_key';
static const String _gatewayDevicePrivateKeyKey =
'xworkmate.gateway.device.private_key';
static const String _ollamaCloudApiKeyKey = 'xworkmate.ollama.cloud.api_key';
static const String _vaultTokenKey = 'xworkmate.vault.token';
static const String _aiGatewayApiKeyKey = 'xworkmate.ai_gateway.api_key';
static const Map<String, String> _legacyFallbackFileNames = <String, String>{
_gatewayTokenKey: 'gateway-token.txt',
_gatewayPasswordKey: 'gateway-password.txt',
_ollamaCloudApiKeyKey: 'ollama-cloud-api-key.txt',
_vaultTokenKey: 'vault-token.txt',
_aiGatewayApiKeyKey: 'ai-gateway-api-key.txt',
};
final Map<String, String> _memorySecure = <String, String>{};
final Future<String?> Function()? _fallbackDirectoryPathResolver;
final Future<String?> Function()? _databasePathResolver;
final SecureStorageClient? _secureStorageOverride;
final bool _enableSecureStorage;
SecureStorageClient? _secureStorage;
bool _initialized = false;
Future<void> initialize() async {
if (_initialized) {
return;
}
if (_enableSecureStorage) {
if (_secureStorageOverride != null) {
_secureStorage = _secureStorageOverride;
} else if (_useDebugSecureStorageFallback()) {
_secureStorage = _buildDebugSecureStorageClient();
} else {
try {
_secureStorage = FlutterSecureStorageClient(
const FlutterSecureStorage(),
);
} catch (_) {
_secureStorage = null;
}
}
}
_initialized = true;
}
Future<String?> loadGatewayToken() => _readSecure(_gatewayTokenKey);
Future<void> saveGatewayToken(String value) =>
_writeSecure(_gatewayTokenKey, value);
Future<void> clearGatewayToken() => _deleteSecure(_gatewayTokenKey);
Future<String?> loadGatewayPassword() => _readSecure(_gatewayPasswordKey);
Future<void> saveGatewayPassword(String value) =>
_writeSecure(_gatewayPasswordKey, value);
Future<void> clearGatewayPassword() => _deleteSecure(_gatewayPasswordKey);
Future<String?> loadOllamaCloudApiKey() => _readSecure(_ollamaCloudApiKeyKey);
Future<void> saveOllamaCloudApiKey(String value) =>
_writeSecure(_ollamaCloudApiKeyKey, value);
Future<String?> loadVaultToken() => _readSecure(_vaultTokenKey);
Future<void> saveVaultToken(String value) =>
_writeSecure(_vaultTokenKey, value);
Future<String?> loadAiGatewayApiKey() => _readSecure(_aiGatewayApiKeyKey);
Future<void> saveAiGatewayApiKey(String value) =>
_writeSecure(_aiGatewayApiKeyKey, value);
Future<void> clearAiGatewayApiKey() => _deleteSecure(_aiGatewayApiKeyKey);
Future<Map<String, String>> loadSecureRefs() async {
await initialize();
final gatewayToken = await loadGatewayToken();
final gatewayPassword = await loadGatewayPassword();
final deviceIdentity = await loadDeviceIdentity();
final deviceToken = deviceIdentity == null
? null
: await loadDeviceToken(
deviceId: deviceIdentity.deviceId,
role: 'operator',
);
final ollamaKey = await loadOllamaCloudApiKey();
final vaultToken = await loadVaultToken();
final aiGatewayApiKey = await loadAiGatewayApiKey();
final secureRefs = <String, String>{};
if (gatewayToken case final value?) {
secureRefs['gateway_token'] = value;
}
if (gatewayPassword case final value?) {
secureRefs['gateway_password'] = value;
}
if (deviceToken case final value?) {
secureRefs['gateway_device_token_operator'] = value;
}
if (ollamaKey case final value?) {
secureRefs['ollama_cloud_api_key'] = value;
}
if (vaultToken case final value?) {
secureRefs['vault_token'] = value;
}
if (aiGatewayApiKey case final value?) {
secureRefs['ai_gateway_api_key'] = value;
}
return secureRefs;
}
Future<LocalDeviceIdentity?> loadDeviceIdentity() async {
await initialize();
final deviceId = await _readSecure(_gatewayDeviceIdKey);
final publicKey = await _readSecure(_gatewayDevicePublicKeyKey);
final privateKey = await _readSecure(_gatewayDevicePrivateKeyKey);
if (deviceId == null || publicKey == null || privateKey == null) {
return null;
}
return LocalDeviceIdentity(
deviceId: deviceId,
publicKeyBase64Url: publicKey,
privateKeyBase64Url: privateKey,
createdAtMs: DateTime.now().millisecondsSinceEpoch,
);
}
Future<void> saveDeviceIdentity(LocalDeviceIdentity identity) async {
await initialize();
await _writeSecure(_gatewayDeviceIdKey, identity.deviceId);
await _writeSecure(_gatewayDevicePublicKeyKey, identity.publicKeyBase64Url);
await _writeSecure(
_gatewayDevicePrivateKeyKey,
identity.privateKeyBase64Url,
);
}
Future<String?> loadDeviceToken({
required String deviceId,
required String role,
}) async {
await initialize();
return _readSecure(_deviceTokenKey(deviceId, role));
}
Future<void> saveDeviceToken({
required String deviceId,
required String role,
required String token,
}) async {
await initialize();
await _writeSecure(_deviceTokenKey(deviceId, role), token);
}
Future<void> clearDeviceToken({
required String deviceId,
required String role,
}) async {
await initialize();
await _deleteSecure(_deviceTokenKey(deviceId, role));
}
Future<List<int>?> loadLegacyLocalStateKeyBytes() async {
await initialize();
final current = (await _readSecureRaw(legacyLocalStateKey))?.trim() ?? '';
if (current.isNotEmpty) {
return _base64UrlDecode(current);
}
final file = await _legacyLocalStateKeyFile();
if (file == null || !await file.exists()) {
return null;
}
final value = (await file.readAsString()).trim();
if (value.isEmpty) {
return null;
}
if (_secureStorage != null) {
try {
await _writeSecureValue(_secureStorage!, legacyLocalStateKey, value);
await file.delete();
} catch (_) {
// Keep the fallback file available for future recovery attempts.
}
}
return _base64UrlDecode(value);
}
Future<void> dispose() async {
_secureStorage = null;
_initialized = false;
_memorySecure.clear();
}
static String maskValue(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty) {
return 'Not set';
}
if (trimmed.length <= 6) {
return '••••••';
}
return '${trimmed.substring(0, 3)}••••${trimmed.substring(trimmed.length - 3)}';
}
Future<String?> _readSecure(String key) async {
await initialize();
final direct = await _readSecureRaw(key);
if (direct != null && direct.trim().isNotEmpty) {
return direct.trim();
}
final migrated = await _migrateLegacyFallbackFile(key);
if (migrated != null && migrated.trim().isNotEmpty) {
return migrated.trim();
}
return _memorySecure[key];
}
Future<String?> _readSecureRaw(String key) async {
if (_secureStorage != null) {
try {
final value = await _readSecureValue(_secureStorage!, key);
if (value != null && value.trim().isNotEmpty) {
_memorySecure[key] = value.trim();
return value.trim();
}
} catch (_) {
if (await _promoteToFileSecureStorageForTests()) {
try {
final value = await _readSecureValue(_secureStorage!, key);
if (value != null && value.trim().isNotEmpty) {
_memorySecure[key] = value.trim();
return value.trim();
}
} catch (_) {
// Fall through to in-memory cache.
}
}
}
}
return _memorySecure[key];
}
Future<void> _writeSecure(String key, String value) async {
await initialize();
final trimmed = value.trim();
if (trimmed.isEmpty) {
return;
}
if (_secureStorage == null &&
!await _promoteToFileSecureStorageForTests()) {
_memorySecure[key] = trimmed;
return;
}
if (_secureStorage != null) {
await _writeSecureValue(_secureStorage!, key, trimmed);
_memorySecure[key] = trimmed;
final file = await _legacyFallbackFile(key);
if (file != null && await file.exists()) {
await file.delete();
}
}
}
Future<void> _deleteSecure(String key) async {
await initialize();
if (_secureStorage != null) {
try {
await _deleteSecureValue(_secureStorage!, key);
} catch (_) {
// Best effort.
}
}
_memorySecure.remove(key);
final file = await _legacyFallbackFile(key);
if (file != null && await file.exists()) {
await file.delete();
}
}
Future<String?> _migrateLegacyFallbackFile(String key) async {
final file = await _legacyFallbackFile(key);
if (file == null || !await file.exists()) {
return null;
}
final value = (await file.readAsString()).trim();
if (value.isEmpty) {
return null;
}
if (_secureStorage != null) {
try {
await _writeSecureValue(_secureStorage!, key, value);
await file.delete();
} catch (_) {
// Leave the fallback file in place if migration fails.
}
}
_memorySecure[key] = value;
return value;
}
Future<File?> _legacyFallbackFile(String key) async {
final fileName = _legacyFallbackFileNames[key];
if (fileName == null) {
return null;
}
final directory = await _resolveFallbackDirectory();
if (directory == null) {
return null;
}
return File('${directory.path}/$fileName');
}
Future<File?> _legacyLocalStateKeyFile() async {
final directory = await _resolveFallbackDirectory();
if (directory == null) {
return null;
}
return File('${directory.path}/local-state-key.txt');
}
Future<Directory?> _resolveFallbackDirectory() async {
final explicit = await _fallbackDirectoryPathResolver?.call();
final explicitTrimmed = explicit?.trim() ?? '';
if (explicitTrimmed.isNotEmpty) {
return _ensureDirectory(explicitTrimmed);
}
final databasePath = await _databasePathResolver?.call();
final databaseTrimmed = databasePath?.trim() ?? '';
if (databaseTrimmed.isNotEmpty) {
return _ensureDirectory(File(databaseTrimmed).parent.path);
}
try {
final supportDirectory = await getApplicationSupportDirectory();
return _ensureDirectory(
'${supportDirectory.path}/xworkmate/gateway-auth',
);
} catch (_) {
return null;
}
}
Future<Directory> _ensureDirectory(String path) async {
final directory = Directory(path);
if (!await directory.exists()) {
await directory.create(recursive: true);
}
return directory;
}
Future<bool> _promoteToFileSecureStorageForTests() async {
if (_secureStorageOverride != null ||
(_databasePathResolver == null &&
_fallbackDirectoryPathResolver == null)) {
return false;
}
_secureStorage = FileSecureStorageClient(() => _resolveFallbackDirectory());
return true;
}
Future<String?> _readSecureValue(SecureStorageClient client, String key) {
final future = client.read(key: key);
if (client is FlutterSecureStorageClient) {
return future.timeout(_secureStorageTimeout);
}
return future;
}
Future<void> _writeSecureValue(
SecureStorageClient client,
String key,
String value,
) {
final future = client.write(key: key, value: value);
if (client is FlutterSecureStorageClient) {
return future.timeout(_secureStorageTimeout);
}
return future;
}
Future<void> _deleteSecureValue(SecureStorageClient client, String key) {
final future = client.delete(key: key);
if (client is FlutterSecureStorageClient) {
return future.timeout(_secureStorageTimeout);
}
return future;
}
bool _useDebugSecureStorageFallback() {
var enabled = false;
assert(() {
enabled = true;
return true;
}());
return enabled;
}
SecureStorageClient _buildDebugSecureStorageClient() {
if (_databasePathResolver != null ||
_fallbackDirectoryPathResolver != null) {
return FileSecureStorageClient(() => _resolveFallbackDirectory());
}
return MemorySecureStorageClient();
}
static String _deviceTokenKey(String deviceId, String role) {
final safeRole = role.trim().isEmpty ? 'operator' : role.trim();
return 'xworkmate.gateway.device_token.$deviceId.$safeRole';
}
static List<int> _base64UrlDecode(String value) {
final normalized = value.replaceAll('-', '+').replaceAll('_', '/');
final padded = normalized + '=' * ((4 - normalized.length % 4) % 4);
return base64.decode(padded);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,821 @@
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 =
FutureOr<sqlite.Database?> Function(String resolvedPath);
class SettingsStore {
SettingsStore({
Future<String?> Function()? fallbackDirectoryPathResolver,
Future<String?> Function()? databasePathResolver,
SecureConfigDatabaseOpener? databaseOpener,
Future<List<int>?> Function()? legacyLocalStateKeyLoader,
}) : _fallbackDirectoryPathResolver = fallbackDirectoryPathResolver,
_databasePathResolver = databasePathResolver,
_databaseOpener = databaseOpener,
_legacyLocalStateKeyLoader = legacyLocalStateKeyLoader;
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 SecureConfigDatabaseOpener? _databaseOpener;
final Future<List<int>?> Function()? _legacyLocalStateKeyLoader;
final Cipher _legacyCipher = AesGcm.with256bits();
final Map<String, String> _memoryStore = <String, String>{};
SharedPreferences? _prefs;
sqlite.Database? _database;
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();
}
Future<void> saveSettingsSnapshot(SettingsSnapshot snapshot) async {
await initialize();
final encoded = snapshot.toJsonString();
await _writeStoredString(settingsKey, encoded);
await _writeDurableStateFile(settingsKey, encoded);
_lastRecoveryReport = const LegacyRecoveryReport();
}
Future<List<AssistantThreadRecord>> loadAssistantThreadRecords() async {
await initialize();
await _ensureLegacyRecoveryIfNeeded();
final raw = await _readStoredString(assistantThreadsKey);
return _decodeAssistantThreadRecords(raw) ??
const <AssistantThreadRecord>[];
}
Future<void> saveAssistantThreadRecords(
List<AssistantThreadRecord> records,
) async {
await initialize();
final encoded = jsonEncode(
records.map((item) => item.toJson()).toList(growable: false),
);
await _writeStoredString(assistantThreadsKey, encoded);
await _writeDurableStateFile(assistantThreadsKey, encoded);
}
Future<void> clearAssistantLocalState() async {
await initialize();
await _deleteStoredString(settingsKey);
await _deleteStoredString(assistantThreadsKey);
await _deleteDurableStateFile(settingsKey);
await _deleteDurableStateFile(assistantThreadsKey);
await _deleteLegacyBackupFile();
}
Future<List<SecretAuditEntry>> loadAuditTrail() async {
await initialize();
final raw = await _readStoredString(auditKey);
if (raw == null || raw.trim().isEmpty) {
return const <SecretAuditEntry>[];
}
try {
final decoded = jsonDecode(raw) as List<dynamic>;
return decoded
.map(
(item) => SecretAuditEntry.fromJson(
(item as Map).cast<String, dynamic>(),
),
)
.toList(growable: false);
} catch (_) {
return const <SecretAuditEntry>[];
}
}
Future<void> appendAudit(SecretAuditEntry entry) async {
final items = (await loadAuditTrail()).toList(growable: true);
items.insert(0, entry);
if (items.length > 40) {
items.removeRange(40, items.length);
}
await _writeStoredString(
auditKey,
jsonEncode(items.map((item) => item.toJson()).toList(growable: false)),
);
}
void dispose() {
final database = _database;
_database = null;
if (database != null) {
try {
database.dispose();
} catch (_) {
// Ignore close errors during teardown.
}
}
_prefs = null;
_initialized = false;
_memoryStore.clear();
}
Future<void> _initializeDatabase() async {
final resolvedPath = await _resolveDatabasePath();
if (resolvedPath != null && resolvedPath.trim().isNotEmpty) {
try {
_database = await _openDatabase(resolvedPath);
} catch (_) {
_database = null;
}
}
if (_database == null) {
try {
final database = sqlite.sqlite3.openInMemory();
_configureDatabase(database);
_database = database;
} catch (_) {
_database = null;
}
}
await _migrateLegacyPrefs();
}
Future<sqlite.Database?> _openDatabase(String resolvedPath) async {
if (_databaseOpener != null) {
final database = await _databaseOpener(resolvedPath);
if (database != null) {
_configureDatabase(database);
}
return database;
}
final file = File(resolvedPath);
await file.parent.create(recursive: true);
final database = sqlite.sqlite3.open(file.path);
_configureDatabase(database);
return database;
}
void _configureDatabase(sqlite.Database database) {
database.execute('''
CREATE TABLE IF NOT EXISTS $databaseTableName (
storage_key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL
)
''');
}
Future<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);
if (_durableStateFileNames.containsKey(key)) {
await _writeDurableStateFile(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),
),
);
await _writeDurableStateFile(
settingsKey,
recoveredSettings.toJsonString(),
);
await _writeDurableStateFile(
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 results = <String>{};
final databasePath = await _resolveDatabasePath();
final fallbackRoot = await _fallbackDirectoryPathResolver?.call();
String? supportPath;
try {
supportPath = (await getApplicationSupportDirectory()).path;
} catch (_) {
supportPath = null;
}
void addPath(String? path) {
final trimmed = path?.trim() ?? '';
if (trimmed.isEmpty) {
return;
}
results.add(trimmed);
}
if (databasePath != null && databasePath.trim().isNotEmpty) {
final directory = File(databasePath).parent.path;
addPath(directory);
addPath(Directory(directory).parent.path);
}
addPath(fallbackRoot);
addPath(fallbackRoot == null ? null : '$fallbackRoot/xworkmate');
addPath(supportPath);
addPath(supportPath == null ? null : '$supportPath/xworkmate');
return results.toList(growable: false);
}
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 {
try {
final resolvedPath = await _databasePathResolver?.call();
final trimmed = resolvedPath?.trim() ?? '';
if (trimmed.isNotEmpty) {
return trimmed;
}
} catch (_) {
// Fall through to the default locations.
}
try {
final supportDirectory = await getApplicationSupportDirectory();
return '${supportDirectory.path}/xworkmate/$databaseFileName';
} catch (_) {
final fallbackRoot = await _fallbackDirectoryPathResolver?.call();
final trimmed = fallbackRoot?.trim() ?? '';
if (trimmed.isEmpty) {
return null;
}
return '$trimmed/$databaseFileName';
}
}
Future<String?> _readStoredString(String key) async {
final memoryValue = _memoryStore[key];
if (memoryValue != null) {
return memoryValue;
}
if (_database != null) {
try {
final result = _database!.select(
'SELECT value FROM $databaseTableName WHERE storage_key = ? LIMIT 1',
<Object?>[key],
);
if (result.isNotEmpty) {
final value = result.first['value'];
if (value is String && value.trim().isNotEmpty) {
return value;
}
}
} catch (_) {
// Fall through to durable fallback.
}
}
final durable = await _readDurableStateFile(key);
if (durable != null) {
return durable;
}
try {
final prefValue = _prefs?.getString(key);
if (prefValue != null && prefValue.trim().isNotEmpty) {
return prefValue;
}
} catch (_) {
// Ignore.
}
return null;
}
Future<void> _writeStoredString(String key, String value) async {
final trimmed = value.trim();
if (trimmed.isEmpty) {
return;
}
_memoryStore[key] = trimmed;
if (_database != null) {
try {
_database!.execute(
'''
INSERT INTO $databaseTableName (storage_key, value, updated_at_ms)
VALUES (?, ?, ?)
ON CONFLICT(storage_key) DO UPDATE SET
value = excluded.value,
updated_at_ms = excluded.updated_at_ms
''',
<Object?>[key, trimmed, DateTime.now().millisecondsSinceEpoch],
);
return;
} catch (_) {
// Fall through to durable file fallback.
}
}
}
Future<void> _deleteStoredString(String key) async {
_memoryStore.remove(key);
if (_database != null) {
try {
_database!.execute(
'DELETE FROM $databaseTableName WHERE storage_key = ?',
<Object?>[key],
);
} catch (_) {
// Ignore.
}
}
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();
if (databasePath == null || databasePath.trim().isEmpty) {
return null;
}
final directory = File(databasePath).parent;
if (!await directory.exists()) {
await directory.create(recursive: true);
}
return File('${directory.path}/$fileName');
}
Future<String?> _readDurableStateFile(String key) async {
final file = await _durableStateFile(key);
if (file == null || !await file.exists()) {
return null;
}
final value = await file.readAsString();
return value.trim().isEmpty ? null : value;
}
Future<void> _writeDurableStateFile(String key, String value) async {
final file = await _durableStateFile(key);
if (file == null) {
return;
}
await file.writeAsString(value, flush: true);
}
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();
if (databasePath == null || databasePath.trim().isEmpty) {
return;
}
final file = File('${File(databasePath).parent.path}/$stateBackupFileName');
if (await file.exists()) {
await file.delete();
}
}
SettingsSnapshot? _decodeSettingsSnapshot(String? raw) {
final trimmed = raw?.trim() ?? '';
if (trimmed.isEmpty) {
return null;
}
try {
final decodedValue = jsonDecode(trimmed);
if (decodedValue is! Map) {
return null;
}
final decoded = decodedValue.cast<String, dynamic>();
if (decoded['storageFormat'] == sealedStateFormat ||
!_looksLikeSettingsSnapshot(decoded)) {
return null;
}
return SettingsSnapshot.fromJson(decoded);
} catch (_) {
return null;
}
}
List<AssistantThreadRecord>? _decodeAssistantThreadRecords(String? raw) {
final trimmed = raw?.trim() ?? '';
if (trimmed.isEmpty) {
return null;
}
try {
final decoded = jsonDecode(trimmed) as List<dynamic>;
return decoded
.whereType<Map>()
.map(
(item) =>
AssistantThreadRecord.fromJson(item.cast<String, dynamic>()),
)
.toList(growable: false);
} catch (_) {
return null;
}
}
bool _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') ||
json.containsKey('aiGateway') ||
json.containsKey('accountUsername') ||
json.containsKey('assistantExecutionTarget');
}
}
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;
}

View File

@ -13,109 +13,139 @@ import 'package:xworkmate/runtime/secure_config_store.dart';
import 'package:xworkmate/theme/app_theme.dart';
void main() {
testWidgets('SettingsPage AI Gateway apply button persists edited fields', (
WidgetTester tester,
) async {
late AppController controller;
await tester.runAsync(() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
controller = AppController(
store: SecureConfigStore(
enableSecureStorage: false,
fallbackDirectoryPathResolver: () async =>
'${Directory.systemTemp.path}/xworkmate-widget-tests',
testWidgets(
'SettingsPage AI Gateway draft/save/apply flow persists edited fields through the global actions',
(WidgetTester tester) async {
late AppController controller;
await tester.runAsync(() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
controller = AppController(
store: SecureConfigStore(
enableSecureStorage: false,
fallbackDirectoryPathResolver: () async =>
'${Directory.systemTemp.path}/xworkmate-widget-tests',
),
);
await _waitFor(() => !controller.initializing);
final staleGateway = controller.settings.aiGateway.copyWith(
name: 'default',
baseUrl: '',
apiKeyRef: 'ai_gateway_api_key',
availableModels: const <String>['stale-model'],
selectedModels: const <String>['stale-model'],
syncState: 'invalid',
syncMessage: 'Missing AI Gateway URL',
);
await controller.saveSettings(
controller.settings.copyWith(
aiGateway: staleGateway,
multiAgent: controller.settings.multiAgent.copyWith(
autoSync: false,
),
),
refreshAfterSave: false,
);
});
addTearDown(controller.dispose);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(1600, 1000);
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
await tester.pumpWidget(
MaterialApp(
locale: const Locale('zh'),
supportedLocales: const [Locale('zh'), Locale('en')],
localizationsDelegates: GlobalMaterialLocalizations.delegates,
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
home: Scaffold(body: SettingsPage(controller: controller)),
),
);
await _waitFor(() => !controller.initializing);
final staleGateway = controller.settings.aiGateway.copyWith(
name: 'default',
baseUrl: '',
apiKeyRef: 'ai_gateway_api_key',
availableModels: const <String>['stale-model'],
selectedModels: const <String>['stale-model'],
syncState: 'invalid',
syncMessage: 'Missing AI Gateway URL',
await tester.pump(const Duration(milliseconds: 200));
await tester.tap(find.text('集成'));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const ValueKey('ai-gateway-name-field')),
'default',
);
await controller.saveSettings(
controller.settings.copyWith(
aiGateway: staleGateway,
multiAgent: controller.settings.multiAgent.copyWith(autoSync: false),
),
refreshAfterSave: false,
await tester.enterText(
find.byKey(const ValueKey('ai-gateway-url-field')),
'https://api.svc.plus/v1',
);
});
addTearDown(controller.dispose);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(1600, 1000);
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
await tester.pumpWidget(
MaterialApp(
locale: const Locale('zh'),
supportedLocales: const [Locale('zh'), Locale('en')],
localizationsDelegates: GlobalMaterialLocalizations.delegates,
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
home: Scaffold(body: SettingsPage(controller: controller)),
),
);
await tester.pump(const Duration(milliseconds: 200));
await tester.tap(find.text('集成'));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const ValueKey('ai-gateway-name-field')),
'default',
);
await tester.enterText(
find.byKey(const ValueKey('ai-gateway-url-field')),
'https://api.svc.plus/v1',
);
await tester.enterText(
find.byKey(const ValueKey('ai-gateway-api-key-ref-field')),
'ai_gateway_api_key',
);
await tester.enterText(
find.byKey(const ValueKey('ai-gateway-api-key-field')),
'live-secret',
);
expect(
tester
.widget<TextField>(find.byKey(const ValueKey('ai-gateway-url-field')))
.controller!
.text,
'https://api.svc.plus/v1',
);
tester
.widget<FilledButton>(
find.byKey(const ValueKey('ai-gateway-apply-button')),
)
.onPressed!();
await tester.pump();
await tester.runAsync(() async {
await _waitFor(
() =>
controller.settings.aiGateway.baseUrl == 'https://api.svc.plus/v1',
await tester.enterText(
find.byKey(const ValueKey('ai-gateway-api-key-ref-field')),
'ai_gateway_api_key',
);
await tester.enterText(
find.byKey(const ValueKey('ai-gateway-api-key-field')),
'live-secret',
);
});
await tester.pump(const Duration(milliseconds: 250));
expect(controller.settings.aiGateway.name, 'default');
expect(controller.settings.aiGateway.baseUrl, 'https://api.svc.plus/v1');
expect(controller.settings.aiGateway.apiKeyRef, 'ai_gateway_api_key');
expect(controller.settings.aiGateway.availableModels, isEmpty);
expect(controller.settings.aiGateway.selectedModels, isEmpty);
expect(controller.settings.aiGateway.syncState, 'idle');
expect(controller.settings.aiGateway.syncMessage, 'Ready to sync models');
expect(find.text('Missing AI Gateway URL'), findsNothing);
expect(find.text('Ready to sync models'), findsOneWidget);
});
expect(
tester
.widget<TextField>(
find.byKey(const ValueKey('ai-gateway-url-field')),
)
.controller!
.text,
'https://api.svc.plus/v1',
);
await tester.ensureVisible(
find.byKey(const ValueKey('ai-gateway-save-draft-button')),
);
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const ValueKey('ai-gateway-save-draft-button')),
);
await tester.pumpAndSettle();
expect(controller.settings.aiGateway.baseUrl, isEmpty);
expect(
controller.settingsDraft.aiGateway.baseUrl,
'https://api.svc.plus/v1',
);
expect(
find.byKey(const ValueKey('settings-global-save-button')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('settings-global-apply-button')),
findsOneWidget,
);
await tester.runAsync(() async {
await controller.persistSettingsDraft();
});
await tester.runAsync(() async {
await _waitFor(() => controller.hasPendingSettingsApply);
});
await tester.pump(const Duration(milliseconds: 250));
expect(controller.hasPendingSettingsApply, isTrue);
await tester.runAsync(() async {
await controller.applySettingsDraft();
});
await tester.pumpAndSettle();
expect(controller.settings.aiGateway.name, 'default');
expect(controller.settings.aiGateway.baseUrl, 'https://api.svc.plus/v1');
expect(controller.settings.aiGateway.apiKeyRef, 'ai_gateway_api_key');
expect(controller.settings.aiGateway.availableModels, isEmpty);
expect(controller.settings.aiGateway.selectedModels, isEmpty);
expect(controller.settings.aiGateway.syncState, 'idle');
expect(controller.settings.aiGateway.syncMessage, 'Ready to sync models');
expect(controller.hasPendingSettingsApply, isFalse);
expect(find.text('Missing AI Gateway URL'), findsNothing);
expect(find.text('Ready to sync models'), findsOneWidget);
},
);
}
Future<void> _waitFor(bool Function() predicate) async {

View File

@ -1,7 +1,6 @@
@TestOn('vm')
library;
import 'dart:async';
import 'dart:convert';
import 'dart:io';
@ -10,8 +9,8 @@ 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/secure_config_store.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
import 'package:xworkmate/runtime/secure_config_store.dart';
void main() {
test(
@ -136,47 +135,66 @@ void main() {
);
test(
'SecureConfigStore persists secure values across instances when secure storage times out',
'SecureConfigStore migrates legacy secret fallback files into primary secure storage',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-config-store-secure-fallback-',
'xworkmate-config-store-secret-fallback-',
);
addTearDown(() async {
if (await tempDirectory.exists()) {
await tempDirectory.delete(recursive: true);
}
});
final databasePath = '${tempDirectory.path}/settings.sqlite3';
final firstStore = SecureConfigStore(
databasePathResolver: () async => databasePath,
final secureStorage = _MapSecureStorageClient();
final store = SecureConfigStore(
fallbackDirectoryPathResolver: () async => tempDirectory.path,
secureStorage: _TimeoutSecureStorageClient(),
secureStorage: secureStorage,
);
await firstStore.saveGatewayToken('token-secret');
await firstStore.saveGatewayPassword('password-secret');
await firstStore.saveAiGatewayApiKey('ai-gateway-secret');
final secondStore = SecureConfigStore(
databasePathResolver: () async => databasePath,
fallbackDirectoryPathResolver: () async => tempDirectory.path,
secureStorage: _TimeoutSecureStorageClient(),
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);
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,
);
final secureRefs = await secondStore.loadSecureRefs();
expect(secureRefs['gateway_token'], 'token-secret');
expect(secureRefs['gateway_password'], 'password-secret');
expect(secureRefs['ai_gateway_api_key'], 'ai-gateway-secret');
},
);
test(
'SecureConfigStore persists encrypted local settings and assistant threads when sqlite is unavailable',
'SecureConfigStore persists plain local settings and assistant threads when sqlite is unavailable',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-config-store-encrypted-local-state-',
'xworkmate-config-store-local-state-',
);
addTearDown(() async {
if (await tempDirectory.exists()) {
@ -184,15 +202,14 @@ void main() {
}
});
final databasePath = '${tempDirectory.path}/settings.sqlite3';
final secureStorage = _MapSecureStorageClient();
final snapshot = SettingsSnapshot.defaults().copyWith(
accountUsername: 'encrypted-user',
assistantLastSessionKey: 'draft:encrypted-1',
accountUsername: 'local-user',
assistantLastSessionKey: 'draft:local-1',
);
const records = <AssistantThreadRecord>[
AssistantThreadRecord(
sessionKey: 'draft:encrypted-1',
title: '加密线程',
sessionKey: 'draft:local-1',
title: '本地线程',
archived: false,
executionTarget: AssistantExecutionTarget.local,
messageViewMode: AssistantMessageViewMode.rendered,
@ -201,7 +218,7 @@ void main() {
GatewayChatMessage(
id: 'assistant-1',
role: 'assistant',
text: 'encrypted message',
text: 'plain local message',
timestampMs: 1700000001000,
toolCallId: null,
toolName: null,
@ -217,7 +234,6 @@ void main() {
databasePathResolver: () async => databasePath,
fallbackDirectoryPathResolver: () async => tempDirectory.path,
databaseOpener: (_) => throw StateError('sqlite unavailable'),
secureStorage: secureStorage,
);
await firstStore.saveSettingsSnapshot(snapshot);
await firstStore.saveAssistantThreadRecords(records);
@ -226,33 +242,26 @@ void main() {
final threadsFile = File('${tempDirectory.path}/assistant-threads.json');
expect(await settingsFile.exists(), isTrue);
expect(await threadsFile.exists(), isTrue);
expect(
await settingsFile.readAsString(),
isNot(contains('encrypted-user')),
);
expect(
await threadsFile.readAsString(),
isNot(contains('encrypted message')),
);
expect(await settingsFile.readAsString(), contains('local-user'));
expect(await threadsFile.readAsString(), contains('plain local message'));
final secondStore = SecureConfigStore(
databasePathResolver: () async => databasePath,
fallbackDirectoryPathResolver: () async => tempDirectory.path,
databaseOpener: (_) => throw StateError('sqlite unavailable'),
secureStorage: secureStorage,
);
final loadedSnapshot = await secondStore.loadSettingsSnapshot();
final loadedThreads = await secondStore.loadAssistantThreadRecords();
expect(loadedSnapshot.accountUsername, 'encrypted-user');
expect(loadedSnapshot.assistantLastSessionKey, 'draft:encrypted-1');
expect(loadedSnapshot.accountUsername, 'local-user');
expect(loadedSnapshot.assistantLastSessionKey, 'draft:local-1');
expect(loadedThreads, hasLength(1));
expect(loadedThreads.single.messages.single.text, 'encrypted message');
expect(loadedThreads.single.messages.single.text, 'plain local message');
},
);
test(
'SecureConfigStore migrates plaintext local state into sealed storage and clears legacy prefs',
'SecureConfigStore migrates plaintext local state into the new settings store and clears legacy prefs',
() async {
final legacySnapshot = SettingsSnapshot.defaults().copyWith(
accountUsername: 'legacy-user',
@ -296,12 +305,10 @@ void main() {
}
});
final databasePath = '${tempDirectory.path}/settings.sqlite3';
final secureStorage = _MapSecureStorageClient();
final store = SecureConfigStore(
databasePathResolver: () async => databasePath,
fallbackDirectoryPathResolver: () async => tempDirectory.path,
secureStorage: secureStorage,
);
final loadedSnapshot = await store.loadSettingsSnapshot();
final loadedThreads = await store.loadAssistantThreadRecords();
@ -314,44 +321,27 @@ void main() {
expect(prefs.getString('xworkmate.settings.snapshot'), isNull);
expect(prefs.getString('xworkmate.assistant.threads'), isNull);
final database = sqlite.sqlite3.open(databasePath);
addTearDown(database.dispose);
final settingsValue =
database
.select(
"SELECT value FROM config_entries WHERE storage_key = 'xworkmate.settings.snapshot' LIMIT 1",
)
.single['value']
as String;
final threadsValue =
database
.select(
"SELECT value FROM config_entries WHERE storage_key = 'xworkmate.assistant.threads' LIMIT 1",
)
.single['value']
as String;
expect(settingsValue, contains('xworkmate.sealed.local-state.v1'));
expect(threadsValue, contains('xworkmate.sealed.local-state.v1'));
expect(settingsValue, isNot(contains('legacy-user')));
expect(threadsValue, isNot(contains('legacy message')));
final backupFile = File(
'${tempDirectory.path}/assistant-state-backup.json',
final settingsValue = _readDatabaseValue(
databasePath,
SettingsStore.settingsKey,
);
expect(await backupFile.exists(), isTrue);
final backupContents = await backupFile.readAsString();
expect(backupContents, contains('sealedState'));
expect(backupContents, isNot(contains('legacy-user')));
expect(backupContents, isNot(contains('legacy message')));
final threadsValue = _readDatabaseValue(
databasePath,
SettingsStore.assistantThreadsKey,
);
expect(settingsValue, contains('legacy-user'));
expect(threadsValue, contains('legacy message'));
expect(settingsValue, isNot(contains(SettingsStore.sealedStateFormat)));
expect(threadsValue, isNot(contains(SettingsStore.sealedStateFormat)));
},
);
test(
'SecureConfigStore migrates legacy local-state key fallback into primary secure storage',
'SecureConfigStore recovers sealed legacy local state when the local-state key is available',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-config-store-local-state-key-migrate-',
'xworkmate-config-store-sealed-recovery-',
);
addTearDown(() async {
if (await tempDirectory.exists()) {
@ -395,7 +385,7 @@ void main() {
await File('${tempDirectory.path}/settings-snapshot.json').writeAsString(
await _sealLocalStateForTest(
key: 'xworkmate.settings.snapshot',
key: SettingsStore.settingsKey,
plaintext: snapshot.toJsonString(),
keyBytes: localStateKey,
),
@ -403,7 +393,7 @@ void main() {
);
await File('${tempDirectory.path}/assistant-threads.json').writeAsString(
await _sealLocalStateForTest(
key: 'xworkmate.assistant.threads',
key: SettingsStore.assistantThreadsKey,
plaintext: jsonEncode(
records.map((item) => item.toJson()).toList(growable: false),
),
@ -422,8 +412,65 @@ void main() {
expect(loadedSnapshot.accountUsername, 'migrated-user');
expect(loadedThreads.single.messages.single.text, 'migrated message');
expect(secureStorage._values['xworkmate.local_state.key'], encodedKey);
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'),
);
},
);
@ -652,11 +699,11 @@ void main() {
);
test(
'SecureConfigStore restores assistant state from backup when primary storage is missing',
'SecureConfigStore restores assistant state from durable files when sqlite entries are missing',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-config-store-backup-restore-',
'xworkmate-config-store-durable-restore-',
);
addTearDown(() async {
if (await tempDirectory.exists()) {
@ -698,17 +745,10 @@ void main() {
await store.saveSettingsSnapshot(snapshot);
await store.saveAssistantThreadRecords(records);
final backupFile = File(
'${tempDirectory.path}/assistant-state-backup.json',
);
expect(await backupFile.exists(), isTrue);
final backupContents = await backupFile.readAsString();
expect(backupContents, isNot(contains('backup-user')));
expect(backupContents, isNot(contains('backup message')));
final database = sqlite.sqlite3.open(databasePath);
addTearDown(database.dispose);
database.execute('DELETE FROM config_entries');
database.execute('DELETE FROM ${SettingsStore.databaseTableName}');
final recoveredStore = SecureConfigStore(
databasePathResolver: () async => databasePath,
@ -775,12 +815,6 @@ void main() {
expect(clearedSnapshot.assistantLastSessionKey, isEmpty);
expect(clearedRecords, isEmpty);
expect(await store.loadGatewayToken(), 'token-secret');
expect(
await File(
'${tempDirectory.path}/assistant-state-backup.json',
).exists(),
isFalse,
);
expect(
await File('${tempDirectory.path}/settings-snapshot.json').exists(),
isFalse,
@ -895,20 +929,21 @@ void main() {
);
}
class _TimeoutSecureStorageClient implements SecureStorageClient {
@override
Future<String?> read({required String key}) async {
throw TimeoutException('secure read timed out');
}
@override
Future<void> write({required String key, required String value}) async {
throw TimeoutException('secure write timed out');
}
@override
Future<void> delete({required String key}) async {
throw TimeoutException('secure delete timed out');
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();
}
}
@ -945,7 +980,7 @@ Future<String> _sealLocalStateForTest({
aad: utf8.encode(key),
);
return jsonEncode(<String, dynamic>{
'storageFormat': 'xworkmate.sealed.local-state.v1',
'storageFormat': SettingsStore.sealedStateFormat,
'nonce': _base64UrlNoPadding(secretBox.nonce),
'cipherText': _base64UrlNoPadding(secretBox.cipherText),
'mac': _base64UrlNoPadding(secretBox.mac.bytes),