Refactor settings persistence and upgrade recovery
This commit is contained in:
parent
44469f65e2
commit
ee3f9ec80b
@ -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)) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
58
lib/runtime/legacy_settings_recovery.dart
Normal file
58
lib/runtime/legacy_settings_recovery.dart
Normal 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? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
537
lib/runtime/secret_store.dart
Normal file
537
lib/runtime/secret_store.dart
Normal 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
821
lib/runtime/settings_store.dart
Normal file
821
lib/runtime/settings_store.dart
Normal 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;
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user