diff --git a/lib/app/app_controller_desktop.dart b/lib/app/app_controller_desktop.dart index 0ae1bca4..c745c5d7 100644 --- a/lib/app/app_controller_desktop.dart +++ b/lib/app/app_controller_desktop.dart @@ -157,6 +157,14 @@ class AppController extends ChangeNotifier { SettingsDetailPage? _settingsDetail; SettingsNavigationContext? _settingsNavigationContext; DetailPanelData? _detailPanel; + SettingsSnapshot _settingsDraft = SettingsSnapshot.defaults(); + SettingsSnapshot _lastAppliedSettings = SettingsSnapshot.defaults(); + final Map _draftSecretValues = {}; + bool _settingsDraftInitialized = false; + bool _pendingSettingsApply = false; + bool _pendingGatewayApply = false; + bool _pendingAiGatewayApply = false; + String _settingsDraftStatusMessage = ''; bool _initializing = true; String? _bootstrapError; StreamSubscription? _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 get agents => _agentsController.agents; List 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 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 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 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 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 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 _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 _persistSettingsSnapshot(SettingsSnapshot snapshot) async { + final sanitized = _sanitizeFeatureFlagSettings( + _sanitizeMultiAgentSettings( + _sanitizeOllamaCloudSettings(_sanitizeCodeAgentSettings(snapshot)), + ), + ); + await _settingsController.saveSnapshot(sanitized); + _settingsDraft = sanitized; + _settingsDraftInitialized = true; + } + + Future _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 _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 _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 _ensureActiveAssistantThread() async { if (!isAiGatewayOnlyMode || !isAssistantTaskArchived(_sessionsController.currentSessionKey)) { diff --git a/lib/app/app_controller_web.dart b/lib/app/app_controller_web.dart index 6fd7f394..b7a36f3d 100644 --- a/lib/app/app_controller_web.dart +++ b/lib/app/app_controller_web.dart @@ -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 _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 _draftSecretValues = {}; 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 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 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 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 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 _persistDraftSecrets() async { + final aiGatewayApiKey = _draftSecretValues[_draftAiGatewayApiKeyKey]; + if ((aiGatewayApiKey ?? '').isNotEmpty) { + _aiGatewayApiKeyCache = aiGatewayApiKey!; + await _store.saveAiGatewayApiKey(_aiGatewayApiKeyCache); + } + _draftSecretValues.clear(); + } + Future _persistThreads() async { final records = _threadRecords.values.toList(growable: false); await _browserSessionRepository.saveThreadRecords(records); diff --git a/lib/features/settings/settings_page.dart b/lib/features/settings/settings_page.dart index f9df49f2..973d69c9 100644 --- a/lib/features/settings/settings_page.dart +++ b/lib/features/settings/settings_page.dart @@ -49,7 +49,6 @@ class _SettingsPageState extends State { 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 { _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 { ), ), 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 { ); } + 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 _buildGeneral( BuildContext context, AppController controller, @@ -467,20 +542,29 @@ class _SettingsPageState extends State { _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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { ), ), 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 { AppController controller, SettingsSnapshot snapshot, ) { - return controller.saveSettings(snapshot); + return controller.saveSettingsDraft(snapshot); + } + + Future _handleTopLevelSave(AppController controller) async { + await _captureVisibleSecretDrafts(controller); + await controller.persistSettingsDraft(); + if (!mounted) { + return; + } + setState(() { + _resetSecureFieldUiAfterPersist(controller); + }); + } + + Future _handleTopLevelApply(AppController controller) async { + await _captureVisibleSecretDrafts(controller); + await controller.applySettingsDraft(); + if (!mounted) { + return; + } + setState(() { + _resetSecureFieldUiAfterPersist(controller); + }); + } + + Future _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 _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 { 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 { }); } - Future _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 _testAiGatewayConnection( AppController controller, SettingsSnapshot settings, @@ -2241,30 +2330,6 @@ class _SettingsPageState extends State { .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 { onStateChanged(const _SecretFieldUiState()); } - Future _persistAiGatewayApiKeyIfNeeded( - AppController controller, { - required bool hasStoredValue, - }) { - return _persistSecureFieldIfNeeded( - controller: _aiGatewayApiKeyController, - hasStoredValue: hasStoredValue, - fieldState: _aiGatewayApiKeyState, - onStateChanged: (value) => setState(() => _aiGatewayApiKeyState = value), - onSubmitted: controller.settingsController.saveAiGatewayApiKey, - ); - } - - Future _persistVaultTokenIfNeeded( - AppController controller, { - required bool hasStoredValue, - }) { - return _persistSecureFieldIfNeeded( - controller: _vaultTokenController, - hasStoredValue: hasStoredValue, - fieldState: _vaultTokenState, - onStateChanged: (value) => setState(() => _vaultTokenState = value), - onSubmitted: controller.settingsController.saveVaultToken, - ); - } - - Future _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 { } } -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 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, ), ); } diff --git a/lib/runtime/legacy_settings_recovery.dart b/lib/runtime/legacy_settings_recovery.dart new file mode 100644 index 00000000..401e6ced --- /dev/null +++ b/lib/runtime/legacy_settings_recovery.dart @@ -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 toJson() { + return { + 'status': status.jsonValue, + 'sourcePath': sourcePath, + 'details': details, + }; + } + + factory LegacyRecoveryReport.fromJson(Map json) { + return LegacyRecoveryReport( + status: LegacyRecoveryStatusCopy.fromJsonValue( + json['status'] as String?, + ), + sourcePath: json['sourcePath'] as String?, + details: json['details'] as String? ?? '', + ); + } +} diff --git a/lib/runtime/secret_store.dart b/lib/runtime/secret_store.dart new file mode 100644 index 00000000..51e4660a --- /dev/null +++ b/lib/runtime/secret_store.dart @@ -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 read({required String key}); + + Future write({required String key, required String value}); + + Future delete({required String key}); +} + +class FlutterSecureStorageClient implements SecureStorageClient { + const FlutterSecureStorageClient(this._storage); + + final FlutterSecureStorage _storage; + + @override + Future read({required String key}) { + return _storage.read(key: key); + } + + @override + Future write({required String key, required String value}) { + return _storage.write(key: key, value: value); + } + + @override + Future delete({required String key}) { + return _storage.delete(key: key); + } +} + +class FileSecureStorageClient implements SecureStorageClient { + FileSecureStorageClient(this._directoryResolver); + + final Future Function() _directoryResolver; + + @override + Future delete({required String key}) async { + final file = await _fileForKey(key); + if (file == null || !await file.exists()) { + return; + } + await file.delete(); + } + + @override + Future 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 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 _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 _values = {}; + + @override + Future delete({required String key}) async { + _values.remove(key); + } + + @override + Future read({required String key}) async { + return _values[key]; + } + + @override + Future write({required String key, required String value}) async { + _values[key] = value; + } +} + +class SecretStore { + SecretStore({ + Future Function()? fallbackDirectoryPathResolver, + Future 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 _legacyFallbackFileNames = { + _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 _memorySecure = {}; + final Future Function()? _fallbackDirectoryPathResolver; + final Future Function()? _databasePathResolver; + final SecureStorageClient? _secureStorageOverride; + final bool _enableSecureStorage; + SecureStorageClient? _secureStorage; + bool _initialized = false; + + Future 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 loadGatewayToken() => _readSecure(_gatewayTokenKey); + + Future saveGatewayToken(String value) => + _writeSecure(_gatewayTokenKey, value); + + Future clearGatewayToken() => _deleteSecure(_gatewayTokenKey); + + Future loadGatewayPassword() => _readSecure(_gatewayPasswordKey); + + Future saveGatewayPassword(String value) => + _writeSecure(_gatewayPasswordKey, value); + + Future clearGatewayPassword() => _deleteSecure(_gatewayPasswordKey); + + Future loadOllamaCloudApiKey() => _readSecure(_ollamaCloudApiKeyKey); + + Future saveOllamaCloudApiKey(String value) => + _writeSecure(_ollamaCloudApiKeyKey, value); + + Future loadVaultToken() => _readSecure(_vaultTokenKey); + + Future saveVaultToken(String value) => + _writeSecure(_vaultTokenKey, value); + + Future loadAiGatewayApiKey() => _readSecure(_aiGatewayApiKeyKey); + + Future saveAiGatewayApiKey(String value) => + _writeSecure(_aiGatewayApiKeyKey, value); + + Future clearAiGatewayApiKey() => _deleteSecure(_aiGatewayApiKeyKey); + + Future> 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 = {}; + 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 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 saveDeviceIdentity(LocalDeviceIdentity identity) async { + await initialize(); + await _writeSecure(_gatewayDeviceIdKey, identity.deviceId); + await _writeSecure(_gatewayDevicePublicKeyKey, identity.publicKeyBase64Url); + await _writeSecure( + _gatewayDevicePrivateKeyKey, + identity.privateKeyBase64Url, + ); + } + + Future loadDeviceToken({ + required String deviceId, + required String role, + }) async { + await initialize(); + return _readSecure(_deviceTokenKey(deviceId, role)); + } + + Future saveDeviceToken({ + required String deviceId, + required String role, + required String token, + }) async { + await initialize(); + await _writeSecure(_deviceTokenKey(deviceId, role), token); + } + + Future clearDeviceToken({ + required String deviceId, + required String role, + }) async { + await initialize(); + await _deleteSecure(_deviceTokenKey(deviceId, role)); + } + + Future?> 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 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 _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 _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 _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 _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 _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 _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 _legacyLocalStateKeyFile() async { + final directory = await _resolveFallbackDirectory(); + if (directory == null) { + return null; + } + return File('${directory.path}/local-state-key.txt'); + } + + Future _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 _ensureDirectory(String path) async { + final directory = Directory(path); + if (!await directory.exists()) { + await directory.create(recursive: true); + } + return directory; + } + + Future _promoteToFileSecureStorageForTests() async { + if (_secureStorageOverride != null || + (_databasePathResolver == null && + _fallbackDirectoryPathResolver == null)) { + return false; + } + _secureStorage = FileSecureStorageClient(() => _resolveFallbackDirectory()); + return true; + } + + Future _readSecureValue(SecureStorageClient client, String key) { + final future = client.read(key: key); + if (client is FlutterSecureStorageClient) { + return future.timeout(_secureStorageTimeout); + } + return future; + } + + Future _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 _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 _base64UrlDecode(String value) { + final normalized = value.replaceAll('-', '+').replaceAll('_', '/'); + final padded = normalized + '=' * ((4 - normalized.length % 4) % 4); + return base64.decode(padded); + } +} diff --git a/lib/runtime/secure_config_store.dart b/lib/runtime/secure_config_store.dart index c9b1e366..2a3fe357 100644 --- a/lib/runtime/secure_config_store.dart +++ b/lib/runtime/secure_config_store.dart @@ -1,16 +1,11 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'dart:math'; - -import '../app/app_metadata.dart'; -import 'package:cryptography/cryptography.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:sqlite3/sqlite3.dart' as sqlite; +export 'legacy_settings_recovery.dart'; +export 'secret_store.dart'; +export 'settings_store.dart'; +import 'legacy_settings_recovery.dart'; import 'runtime_models.dart'; +import 'secret_store.dart'; +import 'settings_store.dart'; class SecureConfigStore { SecureConfigStore({ @@ -19,266 +14,117 @@ class SecureConfigStore { SecureConfigDatabaseOpener? databaseOpener, SecureStorageClient? secureStorage, bool enableSecureStorage = true, - }) : _fallbackDirectoryPathResolver = fallbackDirectoryPathResolver, - _databasePathResolver = databasePathResolver, - _databaseOpener = databaseOpener, - _secureStorageOverride = secureStorage, - _enableSecureStorage = enableSecureStorage; + }) { + _secretStore = SecretStore( + fallbackDirectoryPathResolver: fallbackDirectoryPathResolver, + databasePathResolver: databasePathResolver, + secureStorage: secureStorage, + enableSecureStorage: enableSecureStorage, + ); + _settingsStore = SettingsStore( + fallbackDirectoryPathResolver: fallbackDirectoryPathResolver, + databasePathResolver: databasePathResolver, + databaseOpener: databaseOpener, + legacyLocalStateKeyLoader: _secretStore.loadLegacyLocalStateKeyBytes, + ); + } - static const _settingsKey = 'xworkmate.settings.snapshot'; - static const _auditKey = 'xworkmate.secrets.audit'; - static const _assistantThreadsKey = 'xworkmate.assistant.threads'; - static const _databaseFileName = 'config-store.sqlite3'; - static const _databaseTableName = 'config_entries'; - static const _stateBackupFileName = 'assistant-state-backup.json'; - static const _backupSchemaVersion = 2; - static const _secureStorageTimeout = Duration(seconds: 5); - static const _localStateKeyKey = 'xworkmate.local_state.key'; - static const _sealedStateFormat = 'xworkmate.sealed.local-state.v1'; - static const _assistantStateBackupStorageKey = - 'xworkmate.assistant.state.backup'; + late final SecretStore _secretStore; + late final SettingsStore _settingsStore; - static const _gatewayTokenKey = 'xworkmate.gateway.token'; - static const _gatewayPasswordKey = 'xworkmate.gateway.password'; - static const _gatewayDeviceIdKey = 'xworkmate.gateway.device.id'; - static const _gatewayDevicePublicKeyKey = - 'xworkmate.gateway.device.public_key'; - static const _gatewayDevicePrivateKeyKey = - 'xworkmate.gateway.device.private_key'; - static const _deviceIdentityFallbackFileName = 'gateway-device-identity.json'; - static const _ollamaCloudApiKeyKey = 'xworkmate.ollama.cloud.api_key'; - static const _vaultTokenKey = 'xworkmate.vault.token'; - static const _aiGatewayApiKeyKey = 'xworkmate.ai_gateway.api_key'; - - SharedPreferences? _prefs; - sqlite.Database? _database; - SecureStorageClient? _secureStorage; - final Map _memoryStore = {}; - final Map _memorySecure = {}; - final Future Function()? _fallbackDirectoryPathResolver; - final Future Function()? _databasePathResolver; - final SecureConfigDatabaseOpener? _databaseOpener; - final SecureStorageClient? _secureStorageOverride; - final bool _enableSecureStorage; - bool _initialized = false; - final Cipher _localStateCipher = AesGcm.with256bits(); - final Random _random = Random.secure(); - Future _localStateWriteQueue = Future.value(); - - static const Map _durableStateFileNames = { - _settingsKey: 'settings-snapshot.json', - _assistantThreadsKey: 'assistant-threads.json', - }; - - static const Map _secureFallbackFileNames = { - _gatewayTokenKey: 'gateway-token.txt', - _gatewayPasswordKey: 'gateway-password.txt', - _ollamaCloudApiKeyKey: 'ollama-cloud-api-key.txt', - _vaultTokenKey: 'vault-token.txt', - _aiGatewayApiKeyKey: 'ai-gateway-api-key.txt', - }; + LegacyRecoveryReport get lastRecoveryReport => + _settingsStore.lastRecoveryReport; Future initialize() async { - if (_initialized) { - return; - } - try { - _prefs = await SharedPreferences.getInstance(); - } catch (_) { - _prefs = null; - } - if (_enableSecureStorage) { - if (_secureStorageOverride != null) { - _secureStorage = _secureStorageOverride; - } else if (_useDebugSecureStorageFallback()) { - _secureStorage = _buildDebugSecureStorageClient(); - } else { - try { - _secureStorage = FlutterSecureStorageClient( - const FlutterSecureStorage(), - ); - } catch (_) { - _secureStorage = null; - } - } - } - await _initializeDatabase(); - _initialized = true; + await _secretStore.initialize(); + await _settingsStore.initialize(); } - Future loadSettingsSnapshot() async { - await initialize(); - final state = await _loadAssistantStateFromPrimaryOrBackup(); - return state?.settings ?? SettingsSnapshot.defaults(); + Future loadSettingsSnapshot() { + return _settingsStore.loadSettingsSnapshot(); } - Future saveSettingsSnapshot(SettingsSnapshot snapshot) async { - await _enqueueLocalStateWrite(() async { - await initialize(); - final encoded = snapshot.toJsonString(); - await _writeStoredString(_settingsKey, encoded); - await _writeDurableStateFile(_settingsKey, encoded); - await _persistAssistantStateBackup(settings: snapshot); - }); + Future saveSettingsSnapshot(SettingsSnapshot snapshot) { + return _settingsStore.saveSettingsSnapshot(snapshot); } - Future> loadAssistantThreadRecords() async { - await initialize(); - final state = await _loadAssistantStateFromPrimaryOrBackup(); - return state?.assistantThreads ?? const []; + Future> loadAssistantThreadRecords() { + return _settingsStore.loadAssistantThreadRecords(); } - Future saveAssistantThreadRecords( - List records, - ) async { - await _enqueueLocalStateWrite(() async { - await initialize(); - final encoded = jsonEncode( - records.map((item) => item.toJson()).toList(growable: false), - ); - await _writeStoredString(_assistantThreadsKey, encoded); - await _writeDurableStateFile(_assistantThreadsKey, encoded); - await _persistAssistantStateBackup(assistantThreads: records); - }); + Future saveAssistantThreadRecords(List records) { + return _settingsStore.saveAssistantThreadRecords(records); } - Future clearAssistantLocalState() async { - await _enqueueLocalStateWrite(() async { - await initialize(); - await _deleteStoredString(_settingsKey); - await _deleteStoredString(_assistantThreadsKey); - await _deleteDurableStateFile(_settingsKey); - await _deleteDurableStateFile(_assistantThreadsKey); - await _deleteAssistantStateBackup(); - }); + Future clearAssistantLocalState() { + return _settingsStore.clearAssistantLocalState(); } - Future> loadAuditTrail() async { - await initialize(); - final raw = await _readStoredString(_auditKey); - if (raw == null || raw.trim().isEmpty) { - return const []; - } - try { - final decoded = jsonDecode(raw) as List; - return decoded - .map( - (item) => SecretAuditEntry.fromJson( - (item as Map).cast(), - ), - ) - .toList(growable: false); - } catch (_) { - return const []; - } + Future> loadAuditTrail() { + return _settingsStore.loadAuditTrail(); } - Future 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)), - ); + Future appendAudit(SecretAuditEntry entry) { + return _settingsStore.appendAudit(entry); } - Future loadGatewayToken() => _readSecure(_gatewayTokenKey); + Future> loadSecureRefs() { + return _secretStore.loadSecureRefs(); + } + + Future loadGatewayToken() => _secretStore.loadGatewayToken(); Future saveGatewayToken(String value) => - _writeSecure(_gatewayTokenKey, value); + _secretStore.saveGatewayToken(value); - Future clearGatewayToken() => _deleteSecure(_gatewayTokenKey); + Future clearGatewayToken() => _secretStore.clearGatewayToken(); - Future loadGatewayPassword() => _readSecure(_gatewayPasswordKey); + Future loadGatewayPassword() => _secretStore.loadGatewayPassword(); Future saveGatewayPassword(String value) => - _writeSecure(_gatewayPasswordKey, value); + _secretStore.saveGatewayPassword(value); - Future clearGatewayPassword() => _deleteSecure(_gatewayPasswordKey); + Future clearGatewayPassword() => _secretStore.clearGatewayPassword(); - Future loadOllamaCloudApiKey() => _readSecure(_ollamaCloudApiKeyKey); + Future loadOllamaCloudApiKey() => + _secretStore.loadOllamaCloudApiKey(); Future saveOllamaCloudApiKey(String value) => - _writeSecure(_ollamaCloudApiKeyKey, value); + _secretStore.saveOllamaCloudApiKey(value); - Future loadVaultToken() => _readSecure(_vaultTokenKey); + Future loadVaultToken() => _secretStore.loadVaultToken(); Future saveVaultToken(String value) => - _writeSecure(_vaultTokenKey, value); + _secretStore.saveVaultToken(value); - Future loadAiGatewayApiKey() => _readSecure(_aiGatewayApiKeyKey); + Future loadAiGatewayApiKey() => _secretStore.loadAiGatewayApiKey(); Future saveAiGatewayApiKey(String value) => - _writeSecure(_aiGatewayApiKeyKey, value); + _secretStore.saveAiGatewayApiKey(value); - Future clearAiGatewayApiKey() => _deleteSecure(_aiGatewayApiKeyKey); + Future clearAiGatewayApiKey() => _secretStore.clearAiGatewayApiKey(); - Future 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) { - final fallbackIdentity = await _loadDeviceIdentityFallback(); - if (fallbackIdentity != null) { - await saveDeviceIdentity(fallbackIdentity); - } - return fallbackIdentity; - } - return LocalDeviceIdentity( - deviceId: deviceId, - publicKeyBase64Url: publicKey, - privateKeyBase64Url: privateKey, - createdAtMs: DateTime.now().millisecondsSinceEpoch, - ); + Future loadDeviceIdentity() { + return _secretStore.loadDeviceIdentity(); } - Future saveDeviceIdentity(LocalDeviceIdentity identity) async { - await initialize(); - await _writeSecure(_gatewayDeviceIdKey, identity.deviceId); - await _writeSecure(_gatewayDevicePublicKeyKey, identity.publicKeyBase64Url); - await _writeSecure( - _gatewayDevicePrivateKeyKey, - identity.privateKeyBase64Url, - ); - await _saveDeviceIdentityFallback(identity); + Future saveDeviceIdentity(LocalDeviceIdentity identity) { + return _secretStore.saveDeviceIdentity(identity); } Future loadDeviceToken({ required String deviceId, required String role, - }) async { - await initialize(); - final secureValue = await _readSecure(_deviceTokenKey(deviceId, role)); - if (secureValue != null && secureValue.trim().isNotEmpty) { - return secureValue; - } - final fallbackValue = await _loadDeviceTokenFallback( - deviceId: deviceId, - role: role, - ); - if (fallbackValue != null && fallbackValue.trim().isNotEmpty) { - await saveDeviceToken( - deviceId: deviceId, - role: role, - token: fallbackValue, - ); - return fallbackValue; - } - return null; + }) { + return _secretStore.loadDeviceToken(deviceId: deviceId, role: role); } Future saveDeviceToken({ required String deviceId, required String role, required String token, - }) async { - await initialize(); - await _writeSecure(_deviceTokenKey(deviceId, role), token); - await _saveDeviceTokenFallback( + }) { + return _secretStore.saveDeviceToken( deviceId: deviceId, role: role, token: token, @@ -288,1185 +134,16 @@ class SecureConfigStore { Future clearDeviceToken({ required String deviceId, required String role, - }) async { - await initialize(); - await _deleteSecure(_deviceTokenKey(deviceId, role)); - await _deleteDeviceTokenFallback(deviceId: deviceId, role: role); - } - - Future> 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(); - return { - ...?gatewayToken == null - ? null - : {'gateway_token': gatewayToken}, - ...?gatewayPassword == null - ? null - : {'gateway_password': gatewayPassword}, - ...?deviceToken == null - ? null - : {'gateway_device_token_operator': deviceToken}, - ...?ollamaKey == null - ? null - : {'ollama_cloud_api_key': ollamaKey}, - ...?vaultToken == null - ? null - : {'vault_token': vaultToken}, - ...?aiGatewayApiKey == null - ? null - : {'ai_gateway_api_key': aiGatewayApiKey}, - }; - } - - Future _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 _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 _migrateLegacyPrefs() async { - if (_database == null || _prefs == null) { - return; - } - await _migrateLegacyPrefEntry(_settingsKey); - await _migrateLegacyPrefEntry(_auditKey); - await _migrateLegacyPrefEntry(_assistantThreadsKey); - } - - Future _migrateLegacyPrefEntry(String key) async { - if (_database == null || _prefs == null) { - return; - } - try { - 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', - [key], - ); - if (existing.isEmpty) { - await _writeStoredString(key, legacyValue); - if (_durableStateFileNames.containsKey(key)) { - await _writeDurableStateFile(key, legacyValue); - } - } - await _prefs!.remove(key); - } catch (_) { - return; - } - } - - Future _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 _readStoredString(String key) async { - final memoryValue = _memoryStore[key]; - if (memoryValue != null) { - final restored = await _restorePersistedValue(key, memoryValue); - if (restored != null) { - return restored; - } - } - if (_database != null) { - try { - final result = _database!.select( - 'SELECT value FROM $_databaseTableName WHERE storage_key = ? LIMIT 1', - [key], - ); - if (result.isNotEmpty) { - final value = result.first['value']; - if (value is String) { - final restored = await _restorePersistedValue(key, value); - if (restored != null) { - return restored; - } - } - } - } catch (_) { - // Fall through to durable and in-memory fallback. - } - } - final durableValue = await _readDurableStateFile(key); - if (durableValue != null) { - return durableValue; - } - return null; - } - - Future _deleteStoredString(String key) async { - if (_database != null) { - try { - _database!.execute( - 'DELETE FROM $_databaseTableName WHERE storage_key = ?', - [key], - ); - } catch (_) { - // Fall through to in-memory cleanup. - } - } - _memoryStore.remove(key); - await _deleteDurableStateFile(key); - try { - await _prefs?.remove(key); - } catch (_) { - // Ignore preference cleanup failures. - } - } - - Future _writeStoredString(String key, String value) async { - final persistedValue = await _preparePersistedValue(key, value); - if (persistedValue == null) { - return; - } - _memoryStore[key] = persistedValue; - if (_database != null) { - try { - _writeStoredStringInternal(key, persistedValue); - return; - } catch (_) { - // Fall through to durable and in-memory fallback. - } - } - await _writeDurableStateFile(key, value); - } - - Future<_AssistantStateSnapshot?> - _loadAssistantStateFromPrimaryOrBackup() async { - final rawSettings = await _readStoredString(_settingsKey); - final rawThreads = await _readStoredString(_assistantThreadsKey); - final rawSettingsSealed = _isSealedLocalState(rawSettings); - final rawThreadsSealed = _isSealedLocalState(rawThreads); - final decodedSettings = _decodeSettingsSnapshot(rawSettings); - final decodedThreads = _decodeAssistantThreadRecords(rawThreads); - final backupRead = await _readAssistantStateBackup(); - final backup = backupRead?.snapshot; - final backupWasSealed = backupRead?.sealed ?? false; - final resolvedSettings = - decodedSettings ?? backup?.settings ?? SettingsSnapshot.defaults(); - final resolvedThreads = - decodedThreads ?? - backup?.assistantThreads ?? - const []; - final defaultSettings = SettingsSnapshot.defaults(); - final encodedSettings = resolvedSettings.toJsonString(); - final defaultEncodedSettings = defaultSettings.toJsonString(); - final encodedThreads = jsonEncode( - resolvedThreads.map((item) => item.toJson()).toList(growable: false), - ); - final hasMeaningfulState = - rawSettings != null || - rawThreads != null || - backup != null || - encodedSettings != defaultEncodedSettings || - resolvedThreads.isNotEmpty; - - if (hasMeaningfulState && - (rawSettings == null || - !rawSettingsSealed || - decodedSettings == null)) { - await _writeStoredString(_settingsKey, encodedSettings); - } - if (hasMeaningfulState && - (rawThreads == null || !rawThreadsSealed || decodedThreads == null)) { - await _writeStoredString(_assistantThreadsKey, encodedThreads); - } - if (hasMeaningfulState) { - await _writeDurableStateFile(_settingsKey, encodedSettings); - await _writeDurableStateFile(_assistantThreadsKey, encodedThreads); - } - - if (hasMeaningfulState && - (backup == null || - !backupWasSealed || - jsonEncode(backup.settings.toJson()) != - jsonEncode(resolvedSettings.toJson()) || - jsonEncode( - backup.assistantThreads - .map((item) => item.toJson()) - .toList(growable: false), - ) != - encodedThreads)) { - await _persistAssistantStateBackup( - settings: resolvedSettings, - assistantThreads: resolvedThreads, - ); - } - return _AssistantStateSnapshot( - settings: resolvedSettings, - assistantThreads: resolvedThreads, - ); - } - - SettingsSnapshot? _decodeSettingsSnapshot(String? raw) { - if (raw == null || raw.trim().isEmpty) { - return null; - } - try { - final decoded = jsonDecode(raw) as Map; - return SettingsSnapshot.fromJson(decoded); - } catch (_) { - return null; - } - } - - List? _decodeAssistantThreadRecords(String? raw) { - if (raw == null || raw.trim().isEmpty) { - return null; - } - try { - final decoded = jsonDecode(raw) as List; - return decoded - .whereType() - .map( - (item) => - AssistantThreadRecord.fromJson(item.cast()), - ) - .toList(growable: false); - } catch (_) { - return null; - } - } - - Future _persistAssistantStateBackup({ - SettingsSnapshot? settings, - List? assistantThreads, - }) async { - final resolvedSettings = settings ?? await loadSettingsSnapshot(); - final resolvedThreads = - assistantThreads ?? await loadAssistantThreadRecords(); - final payload = _AssistantStateSnapshot( - settings: resolvedSettings, - assistantThreads: resolvedThreads, - ); - try { - final file = await _assistantStateBackupFile(); - if (file == null) { - return; - } - final plaintext = jsonEncode({ - 'settings': payload.settings.toJson(), - 'assistantThreads': payload.assistantThreads - .map((item) => item.toJson()) - .toList(growable: false), - }); - final sealedPayload = await _sealLocalState( - _assistantStateBackupStorageKey, - plaintext, - ); - await file.writeAsString( - jsonEncode({ - 'schemaVersion': _backupSchemaVersion, - 'appVersion': kAppVersion, - 'backupCreatedAtMs': DateTime.now().millisecondsSinceEpoch, - 'sealedState': sealedPayload, - }), - flush: true, - ); - } catch (_) { - return; - } - } - - Future<_AssistantStateBackupReadResult?> _readAssistantStateBackup() async { - try { - final file = await _assistantStateBackupFile(); - if (file == null || !await file.exists()) { - return null; - } - final decoded = - jsonDecode(await file.readAsString()) as Map; - final sealedState = decoded['sealedState']; - if (sealedState is String && sealedState.trim().isNotEmpty) { - final plaintext = await _restoreLocalState( - _assistantStateBackupStorageKey, - sealedState, - ); - if (plaintext == null || plaintext.trim().isEmpty) { - return null; - } - final payload = jsonDecode(plaintext) as Map; - final settings = SettingsSnapshot.fromJson( - (payload['settings'] as Map?)?.cast() ?? const {}, - ); - final threads = ((payload['assistantThreads'] as List?) ?? const []) - .whereType() - .map( - (item) => - AssistantThreadRecord.fromJson(item.cast()), - ) - .toList(growable: false); - return _AssistantStateBackupReadResult( - snapshot: _AssistantStateSnapshot( - settings: settings, - assistantThreads: threads, - ), - sealed: true, - ); - } - final settings = SettingsSnapshot.fromJson( - (decoded['settings'] as Map?)?.cast() ?? const {}, - ); - final threads = ((decoded['assistantThreads'] as List?) ?? const []) - .whereType() - .map( - (item) => - AssistantThreadRecord.fromJson(item.cast()), - ) - .toList(growable: false); - return _AssistantStateBackupReadResult( - snapshot: _AssistantStateSnapshot( - settings: settings, - assistantThreads: threads, - ), - sealed: false, - ); - } catch (_) { - return null; - } - } - - Future _assistantStateBackupFile() async { - try { - final resolvedPath = await _resolveDatabasePath(); - if (resolvedPath == null || resolvedPath.trim().isEmpty) { - return null; - } - final directory = File(resolvedPath).parent; - if (!await directory.exists()) { - await directory.create(recursive: true); - } - return File('${directory.path}/$_stateBackupFileName'); - } catch (_) { - return null; - } - } - - Future _durableStateFile(String key) async { - final fileName = _durableStateFileNames[key]; - if (fileName == null) { - return null; - } - try { - final resolvedPath = await _resolveDatabasePath(); - if (resolvedPath == null || resolvedPath.trim().isEmpty) { - return null; - } - final directory = File(resolvedPath).parent; - if (!await directory.exists()) { - await directory.create(recursive: true); - } - return File('${directory.path}/$fileName'); - } catch (_) { - return null; - } - } - - Future _readDurableStateFile(String key) async { - try { - final file = await _durableStateFile(key); - if (file == null || !await file.exists()) { - return null; - } - final value = await file.readAsString(); - if (value.trim().isEmpty) { - return null; - } - return _restorePersistedValue(key, value); - } catch (_) { - return null; - } - } - - Future _writeDurableStateFile(String key, String value) async { - try { - final file = await _durableStateFile(key); - if (file == null) { - return; - } - final persistedValue = await _preparePersistedValue(key, value); - if (persistedValue == null) { - return; - } - await file.writeAsString(persistedValue, flush: true); - } catch (_) { - return; - } - } - - Future _deleteDurableStateFile(String key) async { - try { - final file = await _durableStateFile(key); - if (file == null || !await file.exists()) { - return; - } - await file.delete(); - } catch (_) { - return; - } - } - - Future _deleteAssistantStateBackup() async { - try { - final file = await _assistantStateBackupFile(); - if (file == null || !await file.exists()) { - return; - } - await file.delete(); - } catch (_) { - return; - } - } - - bool _shouldSealLocalState(String key) { - return key == _settingsKey || key == _assistantThreadsKey; - } - - bool _isSealedLocalState(String? value) { - final trimmed = value?.trim() ?? ''; - if (trimmed.isEmpty) { - return false; - } - try { - final decoded = jsonDecode(trimmed); - return decoded is Map && - decoded['storageFormat'] == _sealedStateFormat; - } catch (_) { - return false; - } - } - - Future _preparePersistedValue(String key, String value) async { - if (!_shouldSealLocalState(key)) { - return value; - } - return _sealLocalState(key, value); - } - - Future _restorePersistedValue(String key, String value) async { - if (!_shouldSealLocalState(key)) { - return value; - } - return _restoreLocalState(key, value); - } - - Future _sealLocalState(String key, String plaintext) async { - final keyBytes = await _loadOrCreateLocalStateKey(); - final secretBox = await _localStateCipher.encrypt( - utf8.encode(plaintext), - secretKey: SecretKey(keyBytes), - nonce: _randomBytes(12), - aad: utf8.encode(key), - ); - return jsonEncode({ - 'storageFormat': _sealedStateFormat, - 'nonce': _base64UrlEncode(secretBox.nonce), - 'cipherText': _base64UrlEncode(secretBox.cipherText), - 'mac': _base64UrlEncode(secretBox.mac.bytes), - }); - } - - Future _restoreLocalState(String key, String persisted) async { - final trimmed = persisted.trim(); - if (trimmed.isEmpty) { - return null; - } - Map? envelope; - try { - final decoded = jsonDecode(trimmed); - if (decoded is Map && - decoded['storageFormat'] == _sealedStateFormat) { - envelope = decoded; - } - } catch (_) { - return trimmed; - } - if (envelope == null) { - return trimmed; - } - final keyBytes = await _loadLocalStateKey(createIfMissing: false); - if (keyBytes == null) { - return null; - } - try { - final secretBox = SecretBox( - _base64UrlDecode(envelope['cipherText'] as String? ?? ''), - nonce: _base64UrlDecode(envelope['nonce'] as String? ?? ''), - mac: Mac(_base64UrlDecode(envelope['mac'] as String? ?? '')), - ); - final clearText = await _localStateCipher.decrypt( - secretBox, - secretKey: SecretKey(keyBytes), - aad: utf8.encode(key), - ); - return utf8.decode(clearText); - } catch (_) { - return null; - } - } - - Future> _loadOrCreateLocalStateKey() async { - final existing = await _loadLocalStateKey(createIfMissing: false); - if (existing != null && existing.isNotEmpty) { - return existing; - } - final generated = _randomBytes(32); - await _writeSecure(_localStateKeyKey, _base64UrlEncode(generated)); - final persisted = await _loadLocalStateKey(createIfMissing: false); - if (persisted != null && persisted.isNotEmpty) { - return persisted; - } - throw StateError('Local state encryption key unavailable'); - } - - Future?> _loadLocalStateKey({required bool createIfMissing}) async { - final encoded = (await _readSecure(_localStateKeyKey))?.trim() ?? ''; - if (encoded.isNotEmpty) { - return _base64UrlDecode(encoded); - } - if (!createIfMissing) { - return null; - } - return _loadOrCreateLocalStateKey(); - } - - List _randomBytes(int length) { - return List.generate(length, (_) => _random.nextInt(256)); - } - - String _base64UrlEncode(List bytes) { - return base64Url.encode(bytes).replaceAll('=', ''); - } - - List _base64UrlDecode(String value) { - final normalized = value.replaceAll('-', '+').replaceAll('_', '/'); - final padded = normalized + '=' * ((4 - normalized.length % 4) % 4); - return base64.decode(padded); - } - - void _writeStoredStringInternal(String key, String value) { - if (_database == null) { - _memoryStore[key] = value; - return; - } - _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 - ''', - [key, value, DateTime.now().millisecondsSinceEpoch], - ); - } - - Future _readSecure(String key) async { - if (_secureStorage != null) { - try { - final value = await _readSecureValue(_secureStorage!, key); - if (value != null && value.trim().isNotEmpty) { - await _deleteGenericSecureFallback(key); - return value; - } - } catch (_) { - // Keep the primary secure store available for future retries and use - // the persistent fallback only for this operation. - } - } - if (await _promoteToFileSecureStorageForTests()) { - try { - final value = await _readSecureValue(_secureStorage!, key); - if (value != null && value.trim().isNotEmpty) { - return value; - } - } catch (_) { - // Fall through to the standard fallback handling below. - } - } - if (_requiresPrimarySecureStorage(key)) { - final migratedValue = await _migrateLegacyPrimarySecureFallback(key); - if (migratedValue != null && migratedValue.trim().isNotEmpty) { - return migratedValue; - } - return _memorySecure[key]; - } - final persistedFallback = await _loadGenericSecureFallback(key); - if (persistedFallback != null && persistedFallback.trim().isNotEmpty) { - return persistedFallback; - } - return _memorySecure[key]; - } - - Future _enqueueLocalStateWrite(Future Function() action) { - final next = _localStateWriteQueue.catchError((_) {}).then((_) => action()); - _localStateWriteQueue = next.catchError((_) {}); - return next; - } - - Future _writeSecure(String key, String value) async { - if (_secureStorage != null) { - try { - await _writeSecureValue(_secureStorage!, key, value); - await _deleteGenericSecureFallback(key); - if (_requiresPrimarySecureStorage(key)) { - await _deleteLegacyPrimarySecureFallback(key); - } - _memorySecure[key] = value; - return; - } catch (_) { - if (await _promoteToFileSecureStorageForTests()) { - try { - await _writeSecureValue(_secureStorage!, key, value); - await _deleteGenericSecureFallback(key); - if (_requiresPrimarySecureStorage(key)) { - await _deleteLegacyPrimarySecureFallback(key); - } - _memorySecure[key] = value; - return; - } catch (_) { - // Fall through to the normal handling below. - } - } - // Keep the primary secure store available for future retries and fall - // back to a durable local file instead of session-only memory. - } - } - if (_requiresPrimarySecureStorage(key)) { - throw StateError('Primary secure storage unavailable for $key'); - } - _memorySecure[key] = value; - await _saveGenericSecureFallback(key, value); - } - - Future _deleteSecure(String key) async { - if (_secureStorage != null) { - try { - await _deleteSecureValue(_secureStorage!, key); - } catch (_) { - // Best effort. Still clear fallback copies below. - } - } - _memorySecure.remove(key); - await _deleteGenericSecureFallback(key); - if (_requiresPrimarySecureStorage(key)) { - await _deleteLegacyPrimarySecureFallback(key); - } + }) { + return _secretStore.clearDeviceToken(deviceId: deviceId, role: role); } void dispose() { - final database = _database; - _database = null; - if (database != null) { - try { - database.dispose(); - } catch (_) { - // Ignore close errors during teardown. - } - } - _prefs = null; - _secureStorage = null; - _initialized = false; - _memoryStore.clear(); - _memorySecure.clear(); + _settingsStore.dispose(); + _secretStore.dispose(); } 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)}'; - } - - static String _deviceTokenKey(String deviceId, String role) { - final safeRole = role.trim().isEmpty ? 'operator' : role.trim(); - return 'xworkmate.gateway.device_token.$deviceId.$safeRole'; - } - - static String _deviceTokenFallbackFileName(String deviceId, String role) { - final safeRole = role.trim().isEmpty ? 'operator' : role.trim(); - return 'gateway-device-token.$deviceId.$safeRole.txt'; - } - - Future _resolveFallbackDirectory() async { - try { - final resolvedPath = - await _fallbackDirectoryPathResolver?.call() ?? - await _defaultFallbackDirectoryPath(); - final trimmed = resolvedPath?.trim() ?? ''; - if (trimmed.isEmpty) { - return null; - } - final directory = Directory(trimmed); - if (!await directory.exists()) { - await directory.create(recursive: true); - } - return directory; - } catch (_) { - return null; - } - } - - Future _defaultFallbackDirectoryPath() async { - try { - final supportDirectory = await getApplicationSupportDirectory(); - return '${supportDirectory.path}/xworkmate/gateway-auth'; - } catch (_) { - return null; - } - } - - Future _deviceIdentityFallbackFile() async { - final directory = await _resolveFallbackDirectory(); - if (directory == null) { - return null; - } - return File('${directory.path}/$_deviceIdentityFallbackFileName'); - } - - Future _deviceTokenFallbackFile({ - required String deviceId, - required String role, - }) async { - final directory = await _resolveFallbackDirectory(); - if (directory == null) { - return null; - } - return File( - '${directory.path}/${_deviceTokenFallbackFileName(deviceId, role)}', - ); - } - - Future _genericSecureFallbackFile(String key) async { - final fileName = _secureFallbackFileNames[key]; - if (fileName == null) { - return null; - } - final directory = await _resolveFallbackDirectory(); - if (directory == null) { - return null; - } - return File('${directory.path}/$fileName'); - } - - Future _loadGenericSecureFallback(String key) async { - try { - final file = await _genericSecureFallbackFile(key); - if (file == null || !await file.exists()) { - return null; - } - final value = (await file.readAsString()).trim(); - return value.isEmpty ? null : value; - } catch (_) { - return null; - } - } - - Future _saveGenericSecureFallback(String key, String value) async { - try { - final file = await _genericSecureFallbackFile(key); - if (file == null) { - return; - } - await file.writeAsString(value, flush: true); - } catch (_) { - return; - } - } - - Future _deleteGenericSecureFallback(String key) async { - try { - final file = await _genericSecureFallbackFile(key); - if (file == null || !await file.exists()) { - return; - } - await file.delete(); - } catch (_) { - return; - } - } - - bool _requiresPrimarySecureStorage(String key) { - return key == _localStateKeyKey; - } - - Future _legacyPrimarySecureFallbackFile(String key) async { - if (key != _localStateKeyKey) { - return null; - } - final directory = await _resolveFallbackDirectory(); - if (directory == null) { - return null; - } - return File('${directory.path}/local-state-key.txt'); - } - - Future _migrateLegacyPrimarySecureFallback(String key) async { - try { - final file = await _legacyPrimarySecureFallbackFile(key); - if (file == null || !await file.exists()) { - return null; - } - final value = (await file.readAsString()).trim(); - if (value.isEmpty || _secureStorage == null) { - return null; - } - await _writeSecureValue(_secureStorage!, key, value); - _memorySecure[key] = value; - await file.delete(); - return value; - } catch (_) { - return null; - } - } - - Future _deleteLegacyPrimarySecureFallback(String key) async { - try { - final file = await _legacyPrimarySecureFallbackFile(key); - if (file == null || !await file.exists()) { - return; - } - await file.delete(); - } catch (_) { - return; - } - } - - Future _promoteToFileSecureStorageForTests() async { - if (_secureStorageOverride != null || - (_databasePathResolver == null && - _fallbackDirectoryPathResolver == null)) { - return false; - } - _secureStorage = FileSecureStorageClient(() => _resolveFallbackDirectory()); - return true; - } - - Future _readSecureValue(SecureStorageClient client, String key) { - final future = client.read(key: key); - if (client is FlutterSecureStorageClient) { - return future.timeout(_secureStorageTimeout); - } - return future; - } - - Future _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 _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(); - } - - Future _loadDeviceIdentityFallback() async { - try { - final file = await _deviceIdentityFallbackFile(); - if (file == null || !await file.exists()) { - return null; - } - final decoded = - jsonDecode(await file.readAsString()) as Map; - final identity = LocalDeviceIdentity.fromJson(decoded); - if (identity.deviceId.trim().isEmpty || - identity.publicKeyBase64Url.trim().isEmpty || - identity.privateKeyBase64Url.trim().isEmpty) { - return null; - } - return identity; - } catch (_) { - return null; - } - } - - Future _saveDeviceIdentityFallback(LocalDeviceIdentity identity) async { - try { - final file = await _deviceIdentityFallbackFile(); - if (file == null) { - return; - } - await file.writeAsString(jsonEncode(identity.toJson()), flush: true); - } catch (_) { - return; - } - } - - Future _loadDeviceTokenFallback({ - required String deviceId, - required String role, - }) async { - try { - final file = await _deviceTokenFallbackFile( - deviceId: deviceId, - role: role, - ); - if (file == null || !await file.exists()) { - return null; - } - final value = (await file.readAsString()).trim(); - return value.isEmpty ? null : value; - } catch (_) { - return null; - } - } - - Future _saveDeviceTokenFallback({ - required String deviceId, - required String role, - required String token, - }) async { - try { - final file = await _deviceTokenFallbackFile( - deviceId: deviceId, - role: role, - ); - if (file == null) { - return; - } - await file.writeAsString(token, flush: true); - } catch (_) { - return; - } - } - - Future _deleteDeviceTokenFallback({ - required String deviceId, - required String role, - }) async { - try { - final file = await _deviceTokenFallbackFile( - deviceId: deviceId, - role: role, - ); - if (file == null || !await file.exists()) { - return; - } - await file.delete(); - } catch (_) { - return; - } - } -} - -class _AssistantStateSnapshot { - const _AssistantStateSnapshot({ - required this.settings, - required this.assistantThreads, - }); - - final SettingsSnapshot settings; - final List assistantThreads; -} - -class _AssistantStateBackupReadResult { - const _AssistantStateBackupReadResult({ - required this.snapshot, - required this.sealed, - }); - - final _AssistantStateSnapshot snapshot; - final bool sealed; -} - -abstract class SecureStorageClient { - Future read({required String key}); - - Future write({required String key, required String value}); - - Future delete({required String key}); -} - -typedef SecureConfigDatabaseOpener = - FutureOr Function(String resolvedPath); - -class FlutterSecureStorageClient implements SecureStorageClient { - const FlutterSecureStorageClient(this._storage); - - final FlutterSecureStorage _storage; - - @override - Future read({required String key}) { - return _storage.read(key: key); - } - - @override - Future write({required String key, required String value}) { - return _storage.write(key: key, value: value); - } - - @override - Future delete({required String key}) { - return _storage.delete(key: key); - } -} - -class FileSecureStorageClient implements SecureStorageClient { - FileSecureStorageClient(this._directoryResolver); - - final Future Function() _directoryResolver; - - @override - Future delete({required String key}) async { - final file = await _fileForKey(key); - if (file == null || !await file.exists()) { - return; - } - await file.delete(); - } - - @override - Future 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 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 _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 _values = {}; - - @override - Future delete({required String key}) async { - _values.remove(key); - } - - @override - Future read({required String key}) async { - return _values[key]; - } - - @override - Future write({required String key, required String value}) async { - _values[key] = value; + return SecretStore.maskValue(value); } } diff --git a/lib/runtime/settings_store.dart b/lib/runtime/settings_store.dart new file mode 100644 index 00000000..3ee7d034 --- /dev/null +++ b/lib/runtime/settings_store.dart @@ -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 Function(String resolvedPath); + +class SettingsStore { + SettingsStore({ + Future Function()? fallbackDirectoryPathResolver, + Future Function()? databasePathResolver, + SecureConfigDatabaseOpener? databaseOpener, + Future?> 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 _durableStateFileNames = { + settingsKey: 'settings-snapshot.json', + assistantThreadsKey: 'assistant-threads.json', + }; + + final Future Function()? _fallbackDirectoryPathResolver; + final Future Function()? _databasePathResolver; + final SecureConfigDatabaseOpener? _databaseOpener; + final Future?> Function()? _legacyLocalStateKeyLoader; + final Cipher _legacyCipher = AesGcm.with256bits(); + final Map _memoryStore = {}; + SharedPreferences? _prefs; + sqlite.Database? _database; + bool _initialized = false; + bool _recoveryAttempted = false; + LegacyRecoveryReport _lastRecoveryReport = const LegacyRecoveryReport(); + + LegacyRecoveryReport get lastRecoveryReport => _lastRecoveryReport; + + Future initialize() async { + if (_initialized) { + return; + } + try { + _prefs = await SharedPreferences.getInstance(); + } catch (_) { + _prefs = null; + } + await _initializeDatabase(); + _initialized = true; + } + + Future loadSettingsSnapshot() async { + await initialize(); + await _ensureLegacyRecoveryIfNeeded(); + final raw = await _readStoredString(settingsKey); + return _decodeSettingsSnapshot(raw) ?? SettingsSnapshot.defaults(); + } + + Future saveSettingsSnapshot(SettingsSnapshot snapshot) async { + await initialize(); + final encoded = snapshot.toJsonString(); + await _writeStoredString(settingsKey, encoded); + await _writeDurableStateFile(settingsKey, encoded); + _lastRecoveryReport = const LegacyRecoveryReport(); + } + + Future> loadAssistantThreadRecords() async { + await initialize(); + await _ensureLegacyRecoveryIfNeeded(); + final raw = await _readStoredString(assistantThreadsKey); + return _decodeAssistantThreadRecords(raw) ?? + const []; + } + + Future saveAssistantThreadRecords( + List records, + ) async { + await initialize(); + final encoded = jsonEncode( + records.map((item) => item.toJson()).toList(growable: false), + ); + await _writeStoredString(assistantThreadsKey, encoded); + await _writeDurableStateFile(assistantThreadsKey, encoded); + } + + Future clearAssistantLocalState() async { + await initialize(); + await _deleteStoredString(settingsKey); + await _deleteStoredString(assistantThreadsKey); + await _deleteDurableStateFile(settingsKey); + await _deleteDurableStateFile(assistantThreadsKey); + await _deleteLegacyBackupFile(); + } + + Future> loadAuditTrail() async { + await initialize(); + final raw = await _readStoredString(auditKey); + if (raw == null || raw.trim().isEmpty) { + return const []; + } + try { + final decoded = jsonDecode(raw) as List; + return decoded + .map( + (item) => SecretAuditEntry.fromJson( + (item as Map).cast(), + ), + ) + .toList(growable: false); + } catch (_) { + return const []; + } + } + + Future 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 _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 _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 _migrateLegacyPrefs() async { + if (_database == null || _prefs == null) { + return; + } + await _migrateLegacyPrefEntry(settingsKey); + await _migrateLegacyPrefEntry(auditKey); + await _migrateLegacyPrefEntry(assistantThreadsKey); + } + + Future _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', + [key], + ); + if (existing.isEmpty) { + await _writeStoredString(key, legacyValue); + if (_durableStateFileNames.containsKey(key)) { + await _writeDurableStateFile(key, legacyValue); + } + } + await _prefs!.remove(key); + } + + Future _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 _attemptLegacyRecovery({ + required String? currentSettingsRaw, + required String? currentThreadsRaw, + }) async { + final lockedSources = []; + 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 []; + 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> _legacyCandidateDirectories() async { + final results = {}; + 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', + [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; + 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; + return _LegacyBackupReadResult( + snapshot: _AssistantStateSnapshot( + settings: SettingsSnapshot.fromJson( + (payload['settings'] as Map?)?.cast() ?? + const {}, + ), + assistantThreads: + ((payload['assistantThreads'] as List?) ?? const []) + .whereType() + .map( + (item) => AssistantThreadRecord.fromJson( + item.cast(), + ), + ) + .toList(growable: false), + ), + ); + } + final settings = SettingsSnapshot.fromJson( + (decoded['settings'] as Map?)?.cast() ?? const {}, + ); + final threads = ((decoded['assistantThreads'] as List?) ?? const []) + .whereType() + .map( + (item) => + AssistantThreadRecord.fromJson(item.cast()), + ) + .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 _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; + 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 _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 _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', + [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 _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 + ''', + [key, trimmed, DateTime.now().millisecondsSinceEpoch], + ); + return; + } catch (_) { + // Fall through to durable file fallback. + } + } + } + + Future _deleteStoredString(String key) async { + _memoryStore.remove(key); + if (_database != null) { + try { + _database!.execute( + 'DELETE FROM $databaseTableName WHERE storage_key = ?', + [key], + ); + } catch (_) { + // Ignore. + } + } + try { + await _prefs?.remove(key); + } catch (_) { + // Ignore. + } + } + + Future _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 _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 _writeDurableStateFile(String key, String value) async { + final file = await _durableStateFile(key); + if (file == null) { + return; + } + await file.writeAsString(value, flush: true); + } + + Future _deleteDurableStateFile(String key) async { + final file = await _durableStateFile(key); + if (file == null || !await file.exists()) { + return; + } + await file.delete(); + } + + Future _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(); + if (decoded['storageFormat'] == sealedStateFormat || + !_looksLikeSettingsSnapshot(decoded)) { + return null; + } + return SettingsSnapshot.fromJson(decoded); + } catch (_) { + return null; + } + } + + List? _decodeAssistantThreadRecords(String? raw) { + final trimmed = raw?.trim() ?? ''; + if (trimmed.isEmpty) { + return null; + } + try { + final decoded = jsonDecode(trimmed) as List; + return decoded + .whereType() + .map( + (item) => + AssistantThreadRecord.fromJson(item.cast()), + ) + .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 && + decoded['storageFormat'] == sealedStateFormat; + } catch (_) { + return false; + } + } + + static List _base64UrlDecode(String value) { + final normalized = value.replaceAll('-', '+').replaceAll('_', '/'); + final padded = normalized + '=' * ((4 - normalized.length % 4) % 4); + return base64.decode(padded); + } + + bool _looksLikeSettingsSnapshot(Map 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? threads; + final bool locked; +} + +class _LegacyStateReadResult { + const _LegacyStateReadResult({ + this.snapshot, + this.threads, + this.locked = false, + }); + + final SettingsSnapshot? snapshot; + final List? threads; + final bool locked; +} + +class _AssistantStateSnapshot { + const _AssistantStateSnapshot({ + required this.settings, + required this.assistantThreads, + }); + + final SettingsSnapshot settings; + final List assistantThreads; +} + +class _LegacyBackupReadResult { + const _LegacyBackupReadResult({this.snapshot, this.locked = false}); + + final _AssistantStateSnapshot? snapshot; + final bool locked; +} diff --git a/test/features/settings_ai_gateway_persistence_suite.dart b/test/features/settings_ai_gateway_persistence_suite.dart index c17d74fb..7d6d339f 100644 --- a/test/features/settings_ai_gateway_persistence_suite.dart +++ b/test/features/settings_ai_gateway_persistence_suite.dart @@ -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({}); - 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({}); + 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 ['stale-model'], + selectedModels: const ['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 ['stale-model'], - selectedModels: const ['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(find.byKey(const ValueKey('ai-gateway-url-field'))) - .controller! - .text, - 'https://api.svc.plus/v1', - ); - tester - .widget( - 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( + 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 _waitFor(bool Function() predicate) async { diff --git a/test/runtime/secure_config_store_suite.dart b/test/runtime/secure_config_store_suite.dart index 08d21022..95574032 100644 --- a/test/runtime/secure_config_store_suite.dart +++ b/test/runtime/secure_config_store_suite.dart @@ -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({}); 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({}); 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( - 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({}); 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({}); + 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.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({}); 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 read({required String key}) async { - throw TimeoutException('secure read timed out'); - } - - @override - Future write({required String key, required String value}) async { - throw TimeoutException('secure write timed out'); - } - - @override - Future 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 + ''', + [key], + ); + return result.single['value']! as String; + } finally { + database.dispose(); } } @@ -945,7 +980,7 @@ Future _sealLocalStateForTest({ aad: utf8.encode(key), ); return jsonEncode({ - 'storageFormat': 'xworkmate.sealed.local-state.v1', + 'storageFormat': SettingsStore.sealedStateFormat, 'nonce': _base64UrlNoPadding(secretBox.nonce), 'cipherText': _base64UrlNoPadding(secretBox.cipherText), 'mac': _base64UrlNoPadding(secretBox.mac.bytes),