diff --git a/lib/app/app_controller_desktop.dart b/lib/app/app_controller_desktop.dart index 82b0047f..278435c4 100644 --- a/lib/app/app_controller_desktop.dart +++ b/lib/app/app_controller_desktop.dart @@ -31,6 +31,7 @@ import '../runtime/mode_switcher.dart'; import '../runtime/agent_registry.dart'; import '../runtime/multi_agent_orchestrator.dart'; import '../runtime/single_agent_runner.dart'; +import '../runtime/skill_directory_access.dart'; enum CodexCooperationState { notStarted, bridgeOnly, registered } @@ -39,11 +40,13 @@ class _SingleAgentSkillScanRoot { required this.path, required this.source, required this.scope, + this.bookmark = '', }); final String path; final String source; final String scope; + final String bookmark; } const String _singleAgentLocalSkillsCacheRelativePath = @@ -98,6 +101,7 @@ class AppController extends ChangeNotifier { RuntimeCoordinator? runtimeCoordinator, DesktopPlatformService? desktopPlatformService, UiFeatureManifest? uiFeatureManifest, + SkillDirectoryAccessService? skillDirectoryAccessService, List? singleAgentLocalSkillScanRoots, List? availableSingleAgentProvidersOverride, ArisBundleRepository? arisBundleRepository, @@ -141,10 +145,10 @@ class AppController extends ChangeNotifier { _tasksController = DerivedTasksController(); _desktopPlatformService = desktopPlatformService ?? createDesktopPlatformService(); - _singleAgentLocalSkillScanRootOverrides = - (singleAgentLocalSkillScanRoots ?? - (_isFlutterTestEnvironment ? const [] : null)) - ?.toList(growable: false); + _skillDirectoryAccessService = + skillDirectoryAccessService ?? createSkillDirectoryAccessService(); + _singleAgentLocalSkillScanRootOverrides = singleAgentLocalSkillScanRoots + ?.toList(growable: false); _gatewayAcpClient = GatewayAcpClient( endpointResolver: _resolveGatewayAcpEndpoint, ); @@ -187,6 +191,7 @@ class AppController extends ChangeNotifier { late final DevicesController _devicesController; late final DerivedTasksController _tasksController; late final DesktopPlatformService _desktopPlatformService; + late final SkillDirectoryAccessService _skillDirectoryAccessService; late final List? _singleAgentLocalSkillScanRootOverrides; late final GatewayAcpClient _gatewayAcpClient; late final DirectSingleAgentAppServerClient _singleAgentAppServerClient; @@ -247,15 +252,17 @@ class AppController extends ChangeNotifier { String? _bootstrapError; StreamSubscription? _runtimeEventsSubscription; bool _disposed = false; - static bool get _isFlutterTestEnvironment => - Platform.environment.containsKey('FLUTTER_TEST'); + SettingsSnapshot _lastObservedSettingsSnapshot = SettingsSnapshot.defaults(); Future _assistantThreadPersistQueue = Future.value(); + Future _settingsObservationQueue = Future.value(); List<_SingleAgentSkillScanRoot> get _singleAgentGlobalSkillScanRoots => (_singleAgentLocalSkillScanRootOverrides?.map( _singleAgentGlobalSkillScanRootFromOverride, ))?.toList(growable: false) ?? - _defaultSingleAgentGlobalSkillScanRoots; + settings.authorizedSkillDirectories + .map(_singleAgentGlobalSkillScanRootFromAuthorizedDirectory) + .toList(growable: false); WorkspaceDestination get destination => _destination; UiFeatureManifest get uiFeatureManifest => _uiFeatureManifest; @@ -317,6 +324,16 @@ class AppController extends ChangeNotifier { SettingsSnapshot get settings => _settingsController.snapshot; SettingsSnapshot get settingsDraft => _settingsDraftInitialized ? _settingsDraft : settings; + bool get supportsSkillDirectoryAuthorization => + _skillDirectoryAccessService.isSupported; + List get authorizedSkillDirectories => + settings.authorizedSkillDirectories; + List get recommendedAuthorizedSkillDirectoryPaths => + _defaultSingleAgentGlobalSkillScanRoots + .map((item) => item.path) + .toList(growable: false); + String get userHomeDirectory => Platform.environment['HOME']?.trim() ?? ''; + String get settingsYamlPath => defaultUserSettingsFilePath() ?? ''; bool get hasSettingsDraftChanges => settingsDraft.toJsonString() != settings.toJsonString() || _draftSecretValues.isNotEmpty; @@ -2605,6 +2622,62 @@ class AppController extends ChangeNotifier { ); } + Future authorizeSkillDirectory({ + String suggestedPath = '', + }) { + return _skillDirectoryAccessService.authorizeDirectory( + suggestedPath: suggestedPath, + ); + } + + Future saveAuthorizedSkillDirectories( + List directories, + ) async { + if (_disposed) { + return; + } + final previous = settings; + final previousDraft = _settingsDraft; + final hadDraftChanges = hasSettingsDraftChanges; + final draftInitialized = _settingsDraftInitialized; + final pendingSettingsApply = _pendingSettingsApply; + final pendingGatewayApply = _pendingGatewayApply; + final pendingAiGatewayApply = _pendingAiGatewayApply; + await _persistSettingsSnapshot( + previous.copyWith( + authorizedSkillDirectories: normalizeAuthorizedSkillDirectories( + directories: directories, + ), + ), + ); + if (_disposed) { + return; + } + await _applyPersistedSettingsSideEffects( + previous: previous, + current: settings, + refreshAfterSave: false, + ); + _lastAppliedSettings = settings; + if (draftInitialized && hadDraftChanges) { + _settingsDraft = previousDraft.copyWith( + authorizedSkillDirectories: settings.authorizedSkillDirectories, + ); + _settingsDraftInitialized = true; + _pendingSettingsApply = pendingSettingsApply; + _pendingGatewayApply = pendingGatewayApply; + _pendingAiGatewayApply = pendingAiGatewayApply; + } else { + _settingsDraft = settings; + _settingsDraftInitialized = true; + _pendingSettingsApply = false; + _pendingGatewayApply = false; + _pendingAiGatewayApply = false; + _settingsDraftStatusMessage = ''; + } + notifyListeners(); + } + Future toggleAssistantNavigationDestination( WorkspaceDestination destination, ) async { @@ -2892,6 +2965,7 @@ class AppController extends ChangeNotifier { return; } } + _lastObservedSettingsSnapshot = settings; _modelsController.restoreFromSettings(settings.aiGateway); _multiAgentOrchestrator.updateConfig(settings.multiAgent); setActiveAppLanguage(settings.appLanguage); @@ -2947,6 +3021,7 @@ class AppController extends ChangeNotifier { } _settingsDraft = settings; _lastAppliedSettings = settings; + _lastObservedSettingsSnapshot = settings; _settingsDraftInitialized = true; _settingsDraftStatusMessage = ''; } catch (error) { @@ -3069,12 +3144,29 @@ class AppController extends ChangeNotifier { static bool _isGatewayDraftKey(String key) => key.startsWith('gateway_token_') || key.startsWith('gateway_password_'); + bool _authorizedSkillDirectoriesChanged( + SettingsSnapshot previous, + SettingsSnapshot current, + ) { + return jsonEncode( + previous.authorizedSkillDirectories + .map((item) => item.toJson()) + .toList(growable: false), + ) != + jsonEncode( + current.authorizedSkillDirectories + .map((item) => item.toJson()) + .toList(growable: false), + ); + } + Future _persistSettingsSnapshot(SettingsSnapshot snapshot) async { final sanitized = _sanitizeFeatureFlagSettings( _sanitizeMultiAgentSettings( _sanitizeOllamaCloudSettings(_sanitizeCodeAgentSettings(snapshot)), ), ); + _lastObservedSettingsSnapshot = sanitized; await _settingsController.saveSnapshot(sanitized); _settingsDraft = sanitized; _settingsDraftInitialized = true; @@ -3114,6 +3206,16 @@ class AppController extends ChangeNotifier { return; } } + if (_authorizedSkillDirectoriesChanged(previous, current)) { + await _refreshSharedSingleAgentLocalSkillsCache(forceRescan: true); + if (_disposed) { + return; + } + if (assistantExecutionTargetForSession(currentSessionKey) == + AssistantExecutionTarget.singleAgent) { + await refreshSingleAgentSkillsForSession(currentSessionKey); + } + } if (refreshAfterSave) { _recomputeTasks(); } @@ -4122,34 +4224,53 @@ class AppController extends ChangeNotifier { }) async { final dedupedByName = {}; for (final rootSpec in roots) { - final resolvedRootPath = _resolveSingleAgentSkillRootPath( + var resolvedRootPath = _resolveSingleAgentSkillRootPath( rootSpec.path, workspaceRef: workspaceRef, ); if (resolvedRootPath.isEmpty) { continue; } - final root = Directory(resolvedRootPath); - if (!await root.exists()) { - continue; - } - await for (final entity in root.list( - recursive: true, - followLinks: false, - )) { - if (entity is! File || entity.uri.pathSegments.last != 'SKILL.md') { + SkillDirectoryAccessHandle? accessHandle; + try { + if (rootSpec.bookmark.trim().isNotEmpty) { + accessHandle = await _skillDirectoryAccessService.openDirectory( + AuthorizedSkillDirectory( + path: resolvedRootPath, + bookmark: rootSpec.bookmark, + ), + ); + if (accessHandle == null) { + continue; + } + resolvedRootPath = normalizeAuthorizedSkillDirectoryPath( + accessHandle.path, + ); + } + final root = Directory(resolvedRootPath); + if (!await root.exists()) { continue; } - final entry = await _skillEntryFromFile( - entity, - rootSpec, - resolvedRootPath, - ); - final normalizedName = entry.label.trim().toLowerCase(); - if (normalizedName.isEmpty) { - continue; + await for (final entity in root.list( + recursive: true, + followLinks: false, + )) { + if (entity is! File || entity.uri.pathSegments.last != 'SKILL.md') { + continue; + } + final entry = await _skillEntryFromFile( + entity, + rootSpec, + resolvedRootPath, + ); + final normalizedName = entry.label.trim().toLowerCase(); + if (normalizedName.isEmpty) { + continue; + } + dedupedByName[normalizedName] = entry; } - dedupedByName[normalizedName] = entry; + } finally { + await accessHandle?.close(); } } final entries = dedupedByName.values.toList(growable: false); @@ -4198,6 +4319,22 @@ class AppController extends ChangeNotifier { ); } + _SingleAgentSkillScanRoot + _singleAgentGlobalSkillScanRootFromAuthorizedDirectory( + AuthorizedSkillDirectory directory, + ) { + final normalizedPath = normalizeAuthorizedSkillDirectoryPath( + directory.path, + ); + final lowered = normalizedPath.toLowerCase(); + return _SingleAgentSkillScanRoot( + path: normalizedPath, + source: _sourceForSkillRootPath(lowered), + scope: normalizedPath.startsWith('/etc/') ? 'system' : 'user', + bookmark: directory.bookmark, + ); + } + String _resolveSingleAgentSkillRootPath( String rawPath, { String workspaceRef = '', @@ -5223,7 +5360,7 @@ class AppController extends ChangeNotifier { void _attachChildListeners() { _runtimeCoordinator.addListener(_relayChildChange); - _settingsController.addListener(_relayChildChange); + _settingsController.addListener(_handleSettingsControllerChange); _agentsController.addListener(_relayChildChange); _sessionsController.addListener(_relayChildChange); _chatController.addListener(_relayChildChange); @@ -5239,7 +5376,7 @@ class AppController extends ChangeNotifier { void _detachChildListeners() { _runtimeCoordinator.removeListener(_relayChildChange); - _settingsController.removeListener(_relayChildChange); + _settingsController.removeListener(_handleSettingsControllerChange); _agentsController.removeListener(_relayChildChange); _sessionsController.removeListener(_relayChildChange); _chatController.removeListener(_relayChildChange); @@ -5253,6 +5390,66 @@ class AppController extends ChangeNotifier { _multiAgentOrchestrator.removeListener(_relayChildChange); } + void _handleSettingsControllerChange() { + final previous = _lastObservedSettingsSnapshot; + final current = settings; + final previousJson = previous.toJsonString(); + final currentJson = current.toJsonString(); + if (currentJson == previousJson) { + _notifyIfActive(); + return; + } + final hadDraftChanges = + _settingsDraftInitialized && + (_settingsDraft.toJsonString() != previousJson || + _draftSecretValues.isNotEmpty); + if (!_settingsDraftInitialized || !hadDraftChanges) { + _settingsDraft = current; + _settingsDraftInitialized = true; + _settingsDraftStatusMessage = ''; + } + _lastObservedSettingsSnapshot = current; + _settingsObservationQueue = _settingsObservationQueue + .then((_) async { + await _handleObservedSettingsChange( + previous: previous, + current: current, + ); + }) + .catchError((_) {}); + _notifyIfActive(); + } + + Future _handleObservedSettingsChange({ + required SettingsSnapshot previous, + required SettingsSnapshot current, + }) async { + if (_disposed) { + return; + } + setActiveAppLanguage(current.appLanguage); + _multiAgentOrchestrator.updateConfig(current.multiAgent); + if (previous.codexCliPath != current.codexCliPath || + previous.codeAgentRuntimeMode != current.codeAgentRuntimeMode) { + await _refreshResolvedCodexCliPath(); + _registerCodexExternalProvider(); + if (_disposed) { + return; + } + } + if (_authorizedSkillDirectoriesChanged(previous, current)) { + await _refreshSharedSingleAgentLocalSkillsCache(forceRescan: true); + if (_disposed) { + return; + } + if (assistantExecutionTargetForSession(currentSessionKey) == + AssistantExecutionTarget.singleAgent) { + await refreshSingleAgentSkillsForSession(currentSessionKey); + } + } + _notifyIfActive(); + } + void _relayChildChange() { _notifyIfActive(); } diff --git a/lib/app/app_controller_web.dart b/lib/app/app_controller_web.dart index 1c7e6bf3..420c0323 100644 --- a/lib/app/app_controller_web.dart +++ b/lib/app/app_controller_web.dart @@ -106,6 +106,17 @@ class AppController extends ChangeNotifier { SettingsSnapshot get settings => _settings; SettingsSnapshot get settingsDraft => _settingsDraftInitialized ? _settingsDraft : _settings; + bool get supportsSkillDirectoryAuthorization => false; + List get authorizedSkillDirectories => + _settings.authorizedSkillDirectories; + List get recommendedAuthorizedSkillDirectoryPaths => const [ + '/etc/skills', + '~/.agents/skills', + '~/.codex/skills', + '~/.workbuddy/skills', + ]; + String get userHomeDirectory => ''; + String get settingsYamlPath => ''; bool get hasSettingsDraftChanges => settingsDraft.toJsonString() != _settings.toJsonString() || _draftSecretValues.isNotEmpty; @@ -926,6 +937,29 @@ class AppController extends ChangeNotifier { notifyListeners(); } + Future authorizeSkillDirectory({ + String suggestedPath = '', + }) async { + return null; + } + + Future saveAuthorizedSkillDirectories( + List directories, + ) async { + _settings = _settings.copyWith( + authorizedSkillDirectories: normalizeAuthorizedSkillDirectories( + directories: directories, + ), + ); + if (_settingsDraftInitialized) { + _settingsDraft = _settingsDraft.copyWith( + authorizedSkillDirectories: _settings.authorizedSkillDirectories, + ); + } + await _persistSettings(); + notifyListeners(); + } + void saveAiGatewayApiKeyDraft(String value) { _saveSecretDraft(_draftAiGatewayApiKeyKey, value); } diff --git a/lib/features/settings/settings_page.dart b/lib/features/settings/settings_page.dart index 9730dced..035ebd0a 100644 --- a/lib/features/settings/settings_page.dart +++ b/lib/features/settings/settings_page.dart @@ -13,6 +13,7 @@ import '../../runtime/gateway_runtime.dart'; import '../../runtime/runtime_controllers.dart'; import '../../runtime/runtime_models.dart'; import 'codex_integration_card.dart'; +import 'skill_directory_authorization_card.dart'; import '../../widgets/section_tabs.dart'; import '../../widgets/surface_card.dart'; import '../../widgets/top_bar.dart'; @@ -825,6 +826,10 @@ class _SettingsPageState extends State { _GatewayIntegrationSubTab.gateway => 'OpenClaw Gateway', _GatewayIntegrationSubTab.llm => appText('LLM 接入点', 'LLM Endpoints'), _GatewayIntegrationSubTab.acp => appText('ACP 外部接入', 'External ACP'), + _GatewayIntegrationSubTab.skills => appText( + 'SKILLS 目录授权', + 'SKILLS Directory Authorization', + ), }; return [ SectionTabs( @@ -832,6 +837,7 @@ class _SettingsPageState extends State { 'OpenClaw Gateway', appText('LLM 接入点', 'LLM Endpoints'), appText('ACP 外部接入', 'External ACP'), + appText('SKILLS 目录授权', 'SKILLS Directory Authorization'), ], value: tabLabel, onChanged: (value) => setState(() { @@ -839,7 +845,9 @@ class _SettingsPageState extends State { 'OpenClaw Gateway' => _GatewayIntegrationSubTab.gateway, _ when value == appText('LLM 接入点', 'LLM Endpoints') => _GatewayIntegrationSubTab.llm, - _ => _GatewayIntegrationSubTab.acp, + _ when value == appText('ACP 外部接入', 'External ACP') => + _GatewayIntegrationSubTab.acp, + _ => _GatewayIntegrationSubTab.skills, }; }), ), @@ -882,6 +890,9 @@ class _SettingsPageState extends State { _GatewayIntegrationSubTab.acp => [ _buildExternalAcpEndpointManager(context, controller, settings), ], + _GatewayIntegrationSubTab.skills => [ + SkillDirectoryAuthorizationCard(controller: controller), + ], }, ]; } @@ -4762,7 +4773,7 @@ class _WorkflowStep extends StatelessWidget { } } -enum _GatewayIntegrationSubTab { gateway, llm, acp } +enum _GatewayIntegrationSubTab { gateway, llm, acp, skills } enum _LlmEndpointSlot { aiGateway, ollamaLocal, ollamaCloud } diff --git a/lib/features/settings/skill_directory_authorization_card.dart b/lib/features/settings/skill_directory_authorization_card.dart new file mode 100644 index 00000000..6835567f --- /dev/null +++ b/lib/features/settings/skill_directory_authorization_card.dart @@ -0,0 +1,445 @@ +import 'package:flutter/material.dart'; + +import '../../app/app_controller.dart'; +import '../../i18n/app_language.dart'; +import '../../runtime/runtime_models.dart'; +import '../../theme/app_palette.dart'; +import '../../widgets/surface_card.dart'; + +class SkillDirectoryAuthorizationCard extends StatefulWidget { + const SkillDirectoryAuthorizationCard({super.key, required this.controller}); + + final AppController controller; + + @override + State createState() => + _SkillDirectoryAuthorizationCardState(); +} + +class _SkillDirectoryAuthorizationCardState + extends State { + bool _busy = false; + String? _statusMessage; + String? _errorMessage; + + @override + Widget build(BuildContext context) { + final controller = widget.controller; + final theme = Theme.of(context); + final palette = context.palette; + final homeDirectory = controller.userHomeDirectory; + final authorizedDirectories = controller.authorizedSkillDirectories; + final presetPaths = controller.recommendedAuthorizedSkillDirectoryPaths; + final customDirectories = authorizedDirectories + .where( + (directory) => !presetPaths.any( + (preset) => _matchesResolvedPath( + preset, + directory.path, + homeDirectory: homeDirectory, + ), + ), + ) + .toList(growable: false); + + return SurfaceCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + appText('SKILLS 目录授权', 'SKILLS Directory Authorization'), + style: theme.textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + appText( + '只有在这里显式授权的目录才会被扫描为单机智能体 skills。设置中心修改会写入 settings.yaml;外部直接改 settings.yaml 也会热加载回 UI 与技能缓存。', + 'Only directories explicitly granted here are scanned as single-agent skills. Settings Center changes write back to settings.yaml, and external settings.yaml edits hot-reload into the UI and skill cache.', + ), + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 16), + _InfoRow( + label: appText('同步文件', 'Synced File'), + value: controller.settingsYamlPath, + ), + _InfoRow( + label: appText('已授权目录', 'Granted Directories'), + value: '${authorizedDirectories.length}', + ), + const SizedBox(height: 16), + if (!controller.supportsSkillDirectoryAuthorization) + _InlineBanner( + color: Colors.orange, + icon: Icons.info_outline_rounded, + message: appText( + '当前平台不支持目录授权文件选择器。', + 'The current platform does not support the directory authorization picker.', + ), + ) + else ...[ + for (final presetPath in presetPaths) ...[ + _buildDirectoryRow( + context, + title: presetPath, + subtitle: _resolvePathForDisplay( + presetPath, + homeDirectory: homeDirectory, + ), + directory: _findAuthorizedDirectory( + authorizedDirectories, + presetPath, + homeDirectory: homeDirectory, + ), + onAuthorize: () => _authorizeDirectory( + suggestedPath: _resolvePathForDisplay( + presetPath, + homeDirectory: homeDirectory, + ), + ), + ), + const SizedBox(height: 10), + ], + if (customDirectories.isNotEmpty) ...[ + Text( + appText('自定义目录', 'Custom Directories'), + style: theme.textTheme.titleSmall, + ), + const SizedBox(height: 10), + for (final directory in customDirectories) ...[ + _buildDirectoryRow( + context, + title: _displayNameForPath(directory.path), + subtitle: directory.path, + directory: directory, + onAuthorize: () => + _authorizeDirectory(suggestedPath: directory.path), + ), + const SizedBox(height: 10), + ], + ], + Align( + alignment: Alignment.centerLeft, + child: FilledButton.tonalIcon( + onPressed: _busy ? null : () => _authorizeDirectory(), + icon: _busy + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.create_new_folder_outlined), + label: Text(appText('添加自定义目录', 'Add Custom Directory')), + ), + ), + ], + if ((_statusMessage ?? _errorMessage) != null) ...[ + const SizedBox(height: 16), + _InlineBanner( + color: _errorMessage == null ? Colors.green : Colors.red, + icon: _errorMessage == null + ? Icons.check_circle_rounded + : Icons.error_outline_rounded, + message: _errorMessage ?? _statusMessage!, + ), + ], + const SizedBox(height: 12), + Text( + appText( + 'macOS 会通过目录选择器显式授予只读访问,并持久化授权 bookmark;移除目录会立即停止扫描该目录。', + 'On macOS the directory picker grants explicit read-only access and persists the authorization bookmark. Removing a directory stops scanning it immediately.', + ), + style: theme.textTheme.bodySmall?.copyWith( + color: palette.textSecondary, + ), + ), + ], + ), + ); + } + + Widget _buildDirectoryRow( + BuildContext context, { + required String title, + required String subtitle, + required AuthorizedSkillDirectory? directory, + required Future Function() onAuthorize, + }) { + final theme = Theme.of(context); + final palette = context.palette; + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: palette.surfaceSecondary, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: palette.strokeSoft), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded(child: Text(title, style: theme.textTheme.titleSmall)), + _StatusChip(authorized: directory != null), + ], + ), + const SizedBox(height: 6), + Text( + subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: palette.textSecondary, + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilledButton.tonalIcon( + onPressed: _busy ? null : onAuthorize, + icon: Icon( + directory == null + ? Icons.folder_open_rounded + : Icons.refresh_rounded, + ), + label: Text( + directory == null + ? appText('授权目录', 'Authorize') + : appText('重新授权', 'Re-authorize'), + ), + ), + if (directory != null) + OutlinedButton.icon( + onPressed: _busy ? null : () => _removeDirectory(directory), + icon: const Icon(Icons.delete_outline_rounded), + label: Text(appText('移除', 'Remove')), + ), + ], + ), + ], + ), + ); + } + + AuthorizedSkillDirectory? _findAuthorizedDirectory( + List directories, + String candidatePath, { + required String homeDirectory, + }) { + for (final directory in directories) { + if (_matchesResolvedPath( + directory.path, + candidatePath, + homeDirectory: homeDirectory, + )) { + return directory; + } + } + return null; + } + + Future _authorizeDirectory({String suggestedPath = ''}) async { + setState(() { + _busy = true; + _statusMessage = null; + _errorMessage = null; + }); + try { + final granted = await widget.controller.authorizeSkillDirectory( + suggestedPath: suggestedPath, + ); + if (granted == null) { + if (!mounted) { + return; + } + setState(() { + _busy = false; + _statusMessage = appText( + '已取消目录授权。', + 'Directory authorization canceled.', + ); + }); + return; + } + final next = normalizeAuthorizedSkillDirectories( + directories: [ + ...widget.controller.authorizedSkillDirectories.where( + (item) => !_matchesResolvedPath( + item.path, + granted.path, + homeDirectory: widget.controller.userHomeDirectory, + ), + ), + granted, + ], + ); + await widget.controller.saveAuthorizedSkillDirectories(next); + if (!mounted) { + return; + } + setState(() { + _busy = false; + _statusMessage = appText( + '目录已授权并同步到 settings.yaml。', + 'Directory authorized and synced to settings.yaml.', + ); + }); + } catch (error) { + if (!mounted) { + return; + } + setState(() { + _busy = false; + _errorMessage = error.toString(); + }); + } + } + + Future _removeDirectory(AuthorizedSkillDirectory directory) async { + setState(() { + _busy = true; + _statusMessage = null; + _errorMessage = null; + }); + try { + final next = widget.controller.authorizedSkillDirectories + .where( + (item) => !_matchesResolvedPath( + item.path, + directory.path, + homeDirectory: widget.controller.userHomeDirectory, + ), + ) + .toList(growable: false); + await widget.controller.saveAuthorizedSkillDirectories(next); + if (!mounted) { + return; + } + setState(() { + _busy = false; + _statusMessage = appText( + '目录已移除并停止扫描。', + 'Directory removed and no longer scanned.', + ); + }); + } catch (error) { + if (!mounted) { + return; + } + setState(() { + _busy = false; + _errorMessage = error.toString(); + }); + } + } + + String _resolvePathForDisplay(String path, {required String homeDirectory}) { + final normalized = normalizeAuthorizedSkillDirectoryPath(path); + if (normalized.startsWith('~/') && homeDirectory.trim().isNotEmpty) { + return '$homeDirectory/${normalized.substring(2)}'; + } + return normalized; + } + + bool _matchesResolvedPath( + String left, + String right, { + required String homeDirectory, + }) { + return _resolvePathForDisplay(left, homeDirectory: homeDirectory) == + _resolvePathForDisplay(right, homeDirectory: homeDirectory); + } + + String _displayNameForPath(String path) { + final normalized = normalizeAuthorizedSkillDirectoryPath(path); + final segments = normalized.split(RegExp(r'[\\/]')); + return segments.isEmpty ? normalized : segments.last; + } +} + +class _StatusChip extends StatelessWidget { + const _StatusChip({required this.authorized}); + + final bool authorized; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + final color = authorized ? Colors.green : palette.textMuted; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + authorized ? appText('已授权', 'Granted') : appText('未授权', 'Not granted'), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: color, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +class _InlineBanner extends StatelessWidget { + const _InlineBanner({ + required this.color, + required this.icon, + required this.message, + }); + + final Color color; + final IconData icon; + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + Icon(icon, color: color, size: 18), + const SizedBox(width: 10), + Expanded(child: Text(message)), + ], + ), + ); + } +} + +class _InfoRow extends StatelessWidget { + const _InfoRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 120, + child: Text( + label, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: palette.textSecondary), + ), + ), + Expanded(child: SelectableText(value)), + ], + ), + ); + } +} diff --git a/lib/runtime/file_store_support.dart b/lib/runtime/file_store_support.dart index 880af2c1..8fed8676 100644 --- a/lib/runtime/file_store_support.dart +++ b/lib/runtime/file_store_support.dart @@ -13,6 +13,49 @@ void debugOverridePersistentSupportRoot(String? path) { : normalizeStoreDirectoryPath(trimmed); } +String? defaultUserSettingsRootPath({ + Map? environment, + String? operatingSystem, +}) { + final env = environment ?? Platform.environment; + final os = operatingSystem ?? Platform.operatingSystem; + final home = env['HOME']?.trim() ?? ''; + if (home.isEmpty) { + return null; + } + if (os == 'macos') { + return '$home/Library/Application Support/xworkmate'; + } + if (os == 'linux') { + final xdgConfigHome = env['XDG_CONFIG_HOME']?.trim() ?? ''; + if (xdgConfigHome.isNotEmpty) { + return '$xdgConfigHome/xworkmate'; + } + return '$home/.config/xworkmate'; + } + if (os == 'windows') { + final appData = env['APPDATA']?.trim() ?? ''; + if (appData.isNotEmpty) { + return '$appData\\xworkmate'; + } + } + return '$home/.xworkmate'; +} + +String? defaultUserSettingsFilePath({ + Map? environment, + String? operatingSystem, +}) { + final root = defaultUserSettingsRootPath( + environment: environment, + operatingSystem: operatingSystem, + ); + if ((root ?? '').isEmpty) { + return null; + } + return '$root/config/settings.yaml'; +} + enum PersistentStoreScope { settings, tasks, secrets, audit } class PersistentWriteFailure { @@ -133,32 +176,17 @@ class StoreLayoutResolver { if (override != null && override.isNotEmpty) { return override; } + if (Platform.isMacOS) { + final macUserRoot = defaultUserSettingsRootPath(); + if ((macUserRoot ?? '').isNotEmpty) { + return macUserRoot; + } + } try { final supportDirectory = await getApplicationSupportDirectory(); return '${supportDirectory.path}/xworkmate'; } catch (_) { - final home = Platform.environment['HOME']?.trim() ?? ''; - if (home.isEmpty) { - return null; - } - if (Platform.isMacOS) { - return '$home/Library/Application Support/xworkmate'; - } - if (Platform.isLinux) { - final xdgConfigHome = - Platform.environment['XDG_CONFIG_HOME']?.trim() ?? ''; - if (xdgConfigHome.isNotEmpty) { - return '$xdgConfigHome/xworkmate'; - } - return '$home/.config/xworkmate'; - } - if (Platform.isWindows) { - final appData = Platform.environment['APPDATA']?.trim() ?? ''; - if (appData.isNotEmpty) { - return '$appData\\xworkmate'; - } - } - return '$home/.xworkmate'; + return defaultUserSettingsRootPath(); } } diff --git a/lib/runtime/runtime_controllers.dart b/lib/runtime/runtime_controllers.dart index 81ed6298..fe8bba47 100644 --- a/lib/runtime/runtime_controllers.dart +++ b/lib/runtime/runtime_controllers.dart @@ -13,8 +13,14 @@ class SettingsController extends ChangeNotifier { final SecureConfigStore _store; bool _disposed = false; + final List> _settingsWatchSubscriptions = + >[]; + Timer? _settingsReloadDebounce; + Timer? _settingsPollTimer; SettingsSnapshot _snapshot = SettingsSnapshot.defaults(); + String _lastSnapshotJson = SettingsSnapshot.defaults().toJsonString(); + String _lastSettingsFileStamp = ''; Map _secureRefs = const {}; List _auditTrail = const []; String _ollamaStatus = 'Idle'; @@ -39,12 +45,22 @@ class SettingsController extends ChangeNotifier { @override void dispose() { _disposed = true; + _settingsReloadDebounce?.cancel(); + _settingsPollTimer?.cancel(); + for (final subscription in _settingsWatchSubscriptions) { + unawaited(subscription.cancel()); + } + _settingsWatchSubscriptions.clear(); super.dispose(); } Future initialize() async { _snapshot = await _store.loadSettingsSnapshot(); + _lastSnapshotJson = _snapshot.toJsonString(); await _reloadDerivedState(); + await _startSettingsWatcher(); + await _refreshSettingsFileStamp(); + _startSettingsPolling(); notifyListeners(); } @@ -55,13 +71,17 @@ class SettingsController extends ChangeNotifier { Future saveSnapshot(SettingsSnapshot snapshot) async { _snapshot = snapshot; + _lastSnapshotJson = _snapshot.toJsonString(); await _store.saveSettingsSnapshot(snapshot); + await _refreshSettingsFileStamp(); await _reloadDerivedState(); notifyListeners(); } Future resetSnapshot(SettingsSnapshot snapshot) async { _snapshot = snapshot; + _lastSnapshotJson = _snapshot.toJsonString(); + await _refreshSettingsFileStamp(); await _reloadDerivedState(); notifyListeners(); } @@ -790,6 +810,130 @@ class SettingsController extends ChangeNotifier { } return '$base.$profileIndex'; } + + Future _startSettingsWatcher() async { + for (final subscription in _settingsWatchSubscriptions) { + await subscription.cancel(); + } + _settingsWatchSubscriptions.clear(); + final files = await _store.resolvedSettingsFiles(); + final directories = await _store.resolvedSettingsWatchDirectories(); + void scheduleReload() { + _settingsReloadDebounce?.cancel(); + _settingsReloadDebounce = Timer( + const Duration(milliseconds: 160), + () => unawaited(_reloadSettingsFromDiskIfChanged()), + ); + } + + for (final file in files) { + try { + if (await file.exists()) { + _settingsWatchSubscriptions.add( + file.watch().listen((_) { + scheduleReload(); + }), + ); + } + } catch (_) { + // Best effort only. Directory watch below remains as a fallback. + } + } + for (final directory in directories) { + try { + if (!await directory.exists()) { + await directory.create(recursive: true); + } + _settingsWatchSubscriptions.add( + directory.watch().listen((_) { + scheduleReload(); + }), + ); + } catch (_) { + // Best effort only. Missing watch support should not block runtime. + } + } + } + + Future _reloadSettingsFromDiskIfChanged() async { + if (_disposed) { + return; + } + final nextStamp = await _resolveStableSettingsFileStamp(); + if (nextStamp == _lastSettingsFileStamp) { + return; + } + final reload = await _store.reloadSettingsSnapshotResult(); + if (!reload.applied) { + return; + } + _lastSettingsFileStamp = nextStamp; + final next = reload.snapshot; + final nextJson = next.toJsonString(); + if (nextJson == _lastSnapshotJson) { + return; + } + _snapshot = next; + _lastSnapshotJson = nextJson; + await _reloadDerivedState(); + notifyListeners(); + } + + void _startSettingsPolling() { + _settingsPollTimer?.cancel(); + _settingsPollTimer = Timer.periodic(const Duration(seconds: 1), (_) { + unawaited(_pollSettingsFileChanges()); + }); + } + + Future _pollSettingsFileChanges() async { + if (_disposed) { + return; + } + final previousStamp = _lastSettingsFileStamp; + final nextStamp = await _computeSettingsFileStamp(); + if (nextStamp == previousStamp) { + return; + } + await _reloadSettingsFromDiskIfChanged(); + } + + Future _refreshSettingsFileStamp() async { + _lastSettingsFileStamp = await _computeSettingsFileStamp(); + } + + Future _resolveStableSettingsFileStamp() async { + var current = await _computeSettingsFileStamp(); + for (var attempt = 0; attempt < 4; attempt++) { + await Future.delayed(const Duration(milliseconds: 120)); + final next = await _computeSettingsFileStamp(); + if (next == current) { + return next; + } + current = next; + } + return current; + } + + Future _computeSettingsFileStamp() async { + final files = await _store.resolvedSettingsFiles(); + final buffer = StringBuffer(); + for (final file in files) { + buffer.write(file.path); + if (await file.exists()) { + final stat = await file.stat(); + buffer + ..write(':') + ..write(stat.modified.millisecondsSinceEpoch) + ..write(':') + ..write(stat.size); + } else { + buffer.write(':missing'); + } + buffer.write('|'); + } + return buffer.toString(); + } } class _AiGatewayResponseException implements Exception { diff --git a/lib/runtime/runtime_models.dart b/lib/runtime/runtime_models.dart index 2f457509..62ca0cba 100644 --- a/lib/runtime/runtime_models.dart +++ b/lib/runtime/runtime_models.dart @@ -236,6 +236,65 @@ List replaceExternalAcpEndpointForProvider( return normalizeExternalAcpEndpoints(profiles: next); } +String normalizeAuthorizedSkillDirectoryPath(String path) { + final trimmed = path.trim(); + if (trimmed.length <= 1) { + return trimmed; + } + return trimmed.replaceFirst(RegExp(r'[\\/]+$'), ''); +} + +class AuthorizedSkillDirectory { + const AuthorizedSkillDirectory({required this.path, this.bookmark = ''}); + + final String path; + final String bookmark; + + AuthorizedSkillDirectory copyWith({String? path, String? bookmark}) { + return AuthorizedSkillDirectory( + path: normalizeAuthorizedSkillDirectoryPath(path ?? this.path), + bookmark: bookmark ?? this.bookmark, + ); + } + + Map toJson() { + return { + 'path': path, + if (bookmark.trim().isNotEmpty) 'bookmark': bookmark, + }; + } + + factory AuthorizedSkillDirectory.fromJson(Map json) { + return AuthorizedSkillDirectory( + path: normalizeAuthorizedSkillDirectoryPath( + json['path']?.toString() ?? '', + ), + bookmark: json['bookmark']?.toString().trim() ?? '', + ); + } +} + +List normalizeAuthorizedSkillDirectories({ + Iterable? directories, +}) { + final incoming = + directories?.toList(growable: false) ?? + const []; + final normalized = []; + final seen = {}; + for (final item in incoming) { + final path = normalizeAuthorizedSkillDirectoryPath(item.path); + if (path.isEmpty || !seen.add(path)) { + continue; + } + normalized.add( + AuthorizedSkillDirectory(path: path, bookmark: item.bookmark.trim()), + ); + } + normalized.sort((left, right) => left.path.compareTo(right.path)); + return List.unmodifiable(normalized); +} + class AssistantThreadConnectionState { const AssistantThreadConnectionState({ required this.executionTarget, @@ -1306,6 +1365,7 @@ class SettingsSnapshot { required this.defaultProvider, required this.gatewayProfiles, required this.externalAcpEndpoints, + required this.authorizedSkillDirectories, required this.ollamaLocal, required this.ollamaCloud, required this.vault, @@ -1341,6 +1401,7 @@ class SettingsSnapshot { final String defaultProvider; final List gatewayProfiles; final List externalAcpEndpoints; + final List authorizedSkillDirectories; final OllamaLocalConfig ollamaLocal; final OllamaCloudConfig ollamaCloud; final VaultConfig vault; @@ -1377,6 +1438,7 @@ class SettingsSnapshot { defaultProvider: 'gateway', gatewayProfiles: normalizeGatewayProfiles(), externalAcpEndpoints: normalizeExternalAcpEndpoints(), + authorizedSkillDirectories: normalizeAuthorizedSkillDirectories(), ollamaLocal: OllamaLocalConfig.defaults(), ollamaCloud: OllamaCloudConfig.defaults(), vault: VaultConfig.defaults(), @@ -1414,6 +1476,7 @@ class SettingsSnapshot { String? defaultProvider, List? gatewayProfiles, List? externalAcpEndpoints, + List? authorizedSkillDirectories, OllamaLocalConfig? ollamaLocal, OllamaCloudConfig? ollamaCloud, VaultConfig? vault, @@ -1441,6 +1504,12 @@ class SettingsSnapshot { final resolvedExternalAcpEndpoints = externalAcpEndpoints != null ? normalizeExternalAcpEndpoints(profiles: externalAcpEndpoints) : this.externalAcpEndpoints; + final resolvedAuthorizedSkillDirectories = + authorizedSkillDirectories != null + ? normalizeAuthorizedSkillDirectories( + directories: authorizedSkillDirectories, + ) + : this.authorizedSkillDirectories; return SettingsSnapshot( appLanguage: appLanguage ?? this.appLanguage, appActive: appActive ?? this.appActive, @@ -1455,6 +1524,7 @@ class SettingsSnapshot { defaultProvider: defaultProvider ?? this.defaultProvider, gatewayProfiles: resolvedGatewayProfiles, externalAcpEndpoints: resolvedExternalAcpEndpoints, + authorizedSkillDirectories: resolvedAuthorizedSkillDirectories, ollamaLocal: ollamaLocal ?? this.ollamaLocal, ollamaCloud: ollamaCloud ?? this.ollamaCloud, vault: vault ?? this.vault, @@ -1505,6 +1575,9 @@ class SettingsSnapshot { 'externalAcpEndpoints': externalAcpEndpoints .map((item) => item.toJson()) .toList(growable: false), + 'authorizedSkillDirectories': authorizedSkillDirectories + .map((item) => item.toJson()) + .toList(growable: false), 'ollamaLocal': ollamaLocal.toJson(), 'ollamaCloud': ollamaCloud.toJson(), 'vault': vault.toJson(), @@ -1593,6 +1666,16 @@ class SettingsSnapshot { ), ), ); + final authorizedSkillDirectories = normalizeAuthorizedSkillDirectories( + directories: + ((json['authorizedSkillDirectories'] as List?) ?? const []) + .whereType() + .map( + (item) => AuthorizedSkillDirectory.fromJson( + item.cast(), + ), + ), + ); return SettingsSnapshot( appLanguage: AppLanguageCopy.fromJsonValue( json['appLanguage'] as String?, @@ -1622,6 +1705,7 @@ class SettingsSnapshot { SettingsSnapshot.defaults().defaultProvider, gatewayProfiles: gatewayProfiles, externalAcpEndpoints: externalAcpEndpoints, + authorizedSkillDirectories: authorizedSkillDirectories, ollamaLocal: OllamaLocalConfig.fromJson( (json['ollamaLocal'] as Map?)?.cast() ?? const {}, ), diff --git a/lib/runtime/secure_config_store.dart b/lib/runtime/secure_config_store.dart index 28740a9a..c1cd409f 100644 --- a/lib/runtime/secure_config_store.dart +++ b/lib/runtime/secure_config_store.dart @@ -55,10 +55,26 @@ class SecureConfigStore { return _settingsStore.loadSettingsSnapshot(); } + Future reloadSettingsSnapshot() { + return _settingsStore.reloadSettingsSnapshot(); + } + + Future reloadSettingsSnapshotResult() { + return _settingsStore.reloadSettingsSnapshotResult(); + } + Future saveSettingsSnapshot(SettingsSnapshot snapshot) { return _settingsStore.saveSettingsSnapshot(snapshot); } + Future> resolvedSettingsFiles() { + return _settingsStore.resolvedSettingsFiles(); + } + + Future> resolvedSettingsWatchDirectories() { + return _settingsStore.resolvedSettingsWatchDirectories(); + } + Future> loadAssistantThreadRecords() { return _settingsStore.loadAssistantThreadRecords(); } diff --git a/lib/runtime/settings_store.dart b/lib/runtime/settings_store.dart index 281f28df..071f9698 100644 --- a/lib/runtime/settings_store.dart +++ b/lib/runtime/settings_store.dart @@ -8,6 +8,20 @@ import 'runtime_models.dart'; typedef SecureConfigDatabaseOpener = FutureOr Function(String resolvedPath); +enum SettingsSnapshotReloadStatus { applied, invalid } + +class SettingsSnapshotReloadResult { + const SettingsSnapshotReloadResult({ + required this.snapshot, + required this.status, + }); + + final SettingsSnapshot snapshot; + final SettingsSnapshotReloadStatus status; + + bool get applied => status == SettingsSnapshotReloadStatus.applied; +} + class SettingsStore { SettingsStore({ Future Function()? fallbackDirectoryPathResolver, @@ -20,7 +34,10 @@ class SettingsStore { StoreLayoutResolver( localRootPathResolver: databasePathResolver, supportRootPathResolver: defaultSupportDirectoryPathResolver, - ); + ), + _enableUserSettingsMirror = + databasePathResolver == null && + defaultSupportDirectoryPathResolver == null; static const String settingsKey = 'xworkmate.settings.snapshot'; static const String auditKey = 'xworkmate.secrets.audit'; @@ -29,8 +46,11 @@ class SettingsStore { static const String databaseTableName = 'config_entries'; final StoreLayoutResolver _layoutResolver; + final bool _enableUserSettingsMirror; bool _initialized = false; StoreLayout? _layout; + List _settingsFiles = const []; + List _settingsWatchDirectories = const []; SettingsSnapshot _settingsSnapshot = SettingsSnapshot.defaults(); List _threadRecords = const []; List _auditTrail = const []; @@ -49,8 +69,14 @@ class SettingsStore { _initialized = true; try { _layout = await _layoutResolver.resolve(); + _settingsFiles = _resolveSettingsFiles(_layout!); + _settingsWatchDirectories = _resolveSettingsWatchDirectories( + _settingsFiles, + ); } catch (_) { _layout = null; + _settingsFiles = const []; + _settingsWatchDirectories = const []; return; } _settingsSnapshot = await _readSettingsSnapshot(); @@ -63,6 +89,37 @@ class SettingsStore { return _settingsSnapshot; } + Future reloadSettingsSnapshot() async { + final result = await reloadSettingsSnapshotResult(); + return result.snapshot; + } + + Future reloadSettingsSnapshotResult() async { + await initialize(); + final result = await _readSettingsSnapshotResult(); + if (result.status == SettingsSnapshotReloadStatus.invalid) { + return SettingsSnapshotReloadResult( + snapshot: _settingsSnapshot, + status: SettingsSnapshotReloadStatus.invalid, + ); + } + _settingsSnapshot = result.snapshot; + return SettingsSnapshotReloadResult( + snapshot: _settingsSnapshot, + status: SettingsSnapshotReloadStatus.applied, + ); + } + + Future> resolvedSettingsFiles() async { + await initialize(); + return List.from(_settingsFiles); + } + + Future> resolvedSettingsWatchDirectories() async { + await initialize(); + return List.from(_settingsWatchDirectories); + } + Future saveSettingsSnapshot(SettingsSnapshot snapshot) async { await initialize(); _settingsSnapshot = snapshot; @@ -76,10 +133,13 @@ class SettingsStore { return; } try { - await atomicWriteString( - layout.settingsFile, - encodeYamlDocument(snapshot.toJson()), - ); + final contents = encodeYamlDocument(snapshot.toJson()); + for (final file + in _settingsFiles.isEmpty + ? [layout.settingsFile] + : _settingsFiles) { + await atomicWriteString(file, contents); + } _settingsWriteFailure = null; } catch (error) { _settingsWriteFailure = _buildWriteFailure( @@ -173,7 +233,12 @@ class SettingsStore { return; } try { - await deleteIfExists(layout.settingsFile); + final settingsFiles = _settingsFiles.isEmpty + ? [layout.settingsFile] + : _settingsFiles; + for (final file in settingsFiles) { + await deleteIfExists(file); + } _settingsWriteFailure = null; } catch (error) { _settingsWriteFailure = _buildWriteFailure( @@ -238,18 +303,83 @@ class SettingsStore { void dispose() {} Future _readSettingsSnapshot() async { - final layout = _layout; - if (layout == null || !await layout.settingsFile.exists()) { - return SettingsSnapshot.defaults(); + final result = await _readSettingsSnapshotResult(); + return result.status == SettingsSnapshotReloadStatus.invalid + ? SettingsSnapshot.defaults() + : result.snapshot; + } + + Future _readSettingsSnapshotResult() async { + if (_settingsFiles.isEmpty) { + return SettingsSnapshotReloadResult( + snapshot: SettingsSnapshot.defaults(), + status: SettingsSnapshotReloadStatus.applied, + ); } - try { - final raw = await layout.settingsFile.readAsString(); - final decoded = decodeYamlDocument(raw); - if (decoded is Map) { - return SettingsSnapshot.fromJson(decoded.cast()); + var sawExistingFile = false; + var sawInvalidFile = false; + for (final file in _settingsFiles) { + if (!await file.exists()) { + continue; } - } catch (_) {} - return SettingsSnapshot.defaults(); + sawExistingFile = true; + try { + final raw = await file.readAsString(); + final decoded = decodeYamlDocument(raw); + if (decoded is Map) { + return SettingsSnapshotReloadResult( + snapshot: SettingsSnapshot.fromJson( + decoded.cast(), + ), + status: SettingsSnapshotReloadStatus.applied, + ); + } + sawInvalidFile = true; + } catch (_) { + sawInvalidFile = true; + } + } + return SettingsSnapshotReloadResult( + snapshot: SettingsSnapshot.defaults(), + status: sawExistingFile && sawInvalidFile + ? SettingsSnapshotReloadStatus.invalid + : SettingsSnapshotReloadStatus.applied, + ); + } + + List _resolveSettingsFiles(StoreLayout layout) { + final resolved = []; + final seen = {}; + + void addPath(String path) { + final normalized = path.trim(); + if (normalized.isEmpty || !seen.add(normalized)) { + return; + } + resolved.add(File(normalized)); + } + + final userPath = _enableUserSettingsMirror + ? defaultUserSettingsFilePath() + : null; + if ((userPath ?? '').isNotEmpty) { + addPath(userPath!); + } + addPath(layout.settingsFile.path); + return List.unmodifiable(resolved); + } + + List _resolveSettingsWatchDirectories(List files) { + final directories = []; + final seen = {}; + for (final file in files) { + final path = file.parent.path.trim(); + if (path.isEmpty || !seen.add(path)) { + continue; + } + directories.add(Directory(path)); + } + return List.unmodifiable(directories); } Future> _readAssistantThreadRecords() async { diff --git a/lib/runtime/skill_directory_access.dart b/lib/runtime/skill_directory_access.dart new file mode 100644 index 00000000..f487cfde --- /dev/null +++ b/lib/runtime/skill_directory_access.dart @@ -0,0 +1,193 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/services.dart'; + +import 'runtime_models.dart'; + +abstract class SkillDirectoryAccessService { + bool get isSupported; + + Future authorizeDirectory({ + String suggestedPath = '', + }); + + Future openDirectory( + AuthorizedSkillDirectory directory, + ); +} + +class SkillDirectoryAccessHandle { + SkillDirectoryAccessHandle({ + required this.path, + required Future Function() onClose, + this.refreshedBookmark = '', + }) : _onClose = onClose; + + final String path; + final String refreshedBookmark; + final Future Function() _onClose; + + Future close() => _onClose(); +} + +SkillDirectoryAccessService createSkillDirectoryAccessService() { + final isFlutterTest = Platform.environment.containsKey('FLUTTER_TEST'); + if (Platform.isMacOS && !isFlutterTest) { + return MacOsSkillDirectoryAccessService(); + } + if (Platform.isLinux || Platform.isWindows || isFlutterTest) { + return FileSelectorSkillDirectoryAccessService(); + } + return UnsupportedSkillDirectoryAccessService(); +} + +class UnsupportedSkillDirectoryAccessService + implements SkillDirectoryAccessService { + @override + bool get isSupported => false; + + @override + Future authorizeDirectory({ + String suggestedPath = '', + }) async { + return null; + } + + @override + Future openDirectory( + AuthorizedSkillDirectory directory, + ) async { + return null; + } +} + +class FileSelectorSkillDirectoryAccessService + implements SkillDirectoryAccessService { + @override + bool get isSupported => true; + + @override + Future authorizeDirectory({ + String suggestedPath = '', + }) async { + final directoryPath = await getDirectoryPath( + initialDirectory: _initialDirectoryForSuggestion(suggestedPath), + ); + final normalized = normalizeAuthorizedSkillDirectoryPath( + directoryPath ?? '', + ); + if (normalized.isEmpty) { + return null; + } + return AuthorizedSkillDirectory(path: normalized); + } + + @override + Future openDirectory( + AuthorizedSkillDirectory directory, + ) async { + final normalized = normalizeAuthorizedSkillDirectoryPath(directory.path); + if (normalized.isEmpty) { + return null; + } + return SkillDirectoryAccessHandle( + path: normalized, + refreshedBookmark: directory.bookmark, + onClose: () async {}, + ); + } +} + +class MacOsSkillDirectoryAccessService implements SkillDirectoryAccessService { + static const MethodChannel _channel = MethodChannel( + 'plus.svc.xworkmate/skill_directory_access', + ); + + @override + bool get isSupported => true; + + @override + Future authorizeDirectory({ + String suggestedPath = '', + }) async { + final response = await _channel.invokeMapMethod( + 'authorizeDirectory', + {'suggestedPath': suggestedPath}, + ); + if (response == null) { + return null; + } + final normalized = normalizeAuthorizedSkillDirectoryPath( + response['path']?.toString() ?? '', + ); + if (normalized.isEmpty) { + return null; + } + return AuthorizedSkillDirectory( + path: normalized, + bookmark: response['bookmark']?.toString().trim() ?? '', + ); + } + + @override + Future openDirectory( + AuthorizedSkillDirectory directory, + ) async { + final bookmark = directory.bookmark.trim(); + final normalizedPath = normalizeAuthorizedSkillDirectoryPath( + directory.path, + ); + if (bookmark.isEmpty) { + if (normalizedPath.isEmpty) { + return null; + } + return SkillDirectoryAccessHandle( + path: normalizedPath, + refreshedBookmark: directory.bookmark, + onClose: () async {}, + ); + } + final response = await _channel.invokeMapMethod( + 'startDirectoryAccess', + {'bookmark': bookmark}, + ); + if (response == null) { + return null; + } + final accessId = response['accessId']?.toString().trim() ?? ''; + final resolvedPath = normalizeAuthorizedSkillDirectoryPath( + response['path']?.toString() ?? normalizedPath, + ); + if (accessId.isEmpty || resolvedPath.isEmpty) { + return null; + } + final refreshedBookmark = + response['bookmark']?.toString().trim().isNotEmpty == true + ? response['bookmark'].toString().trim() + : directory.bookmark; + return SkillDirectoryAccessHandle( + path: resolvedPath, + refreshedBookmark: refreshedBookmark, + onClose: () async { + await _channel.invokeMethod( + 'stopDirectoryAccess', + {'accessId': accessId}, + ); + }, + ); + } +} + +String _initialDirectoryForSuggestion(String suggestedPath) { + final trimmed = normalizeAuthorizedSkillDirectoryPath(suggestedPath); + if (trimmed.isEmpty) { + return ''; + } + final directory = Directory(trimmed); + if (directory.existsSync()) { + return directory.parent.path; + } + return directory.parent.path; +} diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index b3c17614..9e9df0aa 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -3,6 +3,24 @@ import FlutterMacOS @main class AppDelegate: FlutterAppDelegate { + private let skillDirectoryChannelName = "plus.svc.xworkmate/skill_directory_access" + private var directoryAccessSessions: [String: URL] = [:] + + override func applicationDidFinishLaunching(_ notification: Notification) { + super.applicationDidFinishLaunching(notification) + + guard let controller = mainFlutterWindow?.contentViewController as? FlutterViewController else { + return + } + let channel = FlutterMethodChannel( + name: skillDirectoryChannelName, + binaryMessenger: controller.engine.binaryMessenger + ) + channel.setMethodCallHandler { [weak self] call, result in + self?.handleSkillDirectoryCall(call, result: result) + } + } + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } @@ -10,4 +28,163 @@ class AppDelegate: FlutterAppDelegate { override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { return true } + + override func applicationWillTerminate(_ notification: Notification) { + for (_, url) in directoryAccessSessions { + url.stopAccessingSecurityScopedResource() + } + directoryAccessSessions.removeAll() + super.applicationWillTerminate(notification) + } + + private func handleSkillDirectoryCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "authorizeDirectory": + authorizeDirectory(call, result: result) + case "startDirectoryAccess": + startDirectoryAccess(call, result: result) + case "stopDirectoryAccess": + stopDirectoryAccess(call, result: result) + default: + result(FlutterMethodNotImplemented) + } + } + + private func authorizeDirectory(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + let arguments = call.arguments as? [String: Any] + let suggestedPath = (arguments?["suggestedPath"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + + let panel = NSOpenPanel() + panel.title = "授权技能目录" + panel.message = "请选择要授予 XWorkmate 只读访问权限的技能目录。" + panel.prompt = "授权" + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + panel.canCreateDirectories = false + panel.resolvesAliases = true + panel.showsHiddenFiles = true + if let initialURL = initialDirectoryURL(for: suggestedPath) { + panel.directoryURL = initialURL + } + + guard panel.runModal() == .OK, let selectedURL = panel.url else { + result(nil) + return + } + + do { + let resolvedURL = selectedURL.standardizedFileURL + let bookmarkData = try resolvedURL.bookmarkData( + options: [.withSecurityScope], + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + result([ + "path": resolvedURL.path, + "bookmark": bookmarkData.base64EncodedString(), + ]) + } catch { + result( + FlutterError( + code: "bookmark_create_failed", + message: error.localizedDescription, + details: nil + ) + ) + } + } + + private func startDirectoryAccess(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + let arguments = call.arguments as? [String: Any] + let bookmark = (arguments?["bookmark"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !bookmark.isEmpty, let bookmarkData = Data(base64Encoded: bookmark) else { + result( + FlutterError( + code: "invalid_bookmark", + message: "Missing directory bookmark.", + details: nil + ) + ) + return + } + + do { + var isStale = false + let url = try URL( + resolvingBookmarkData: bookmarkData, + options: [.withSecurityScope], + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) + guard url.startAccessingSecurityScopedResource() else { + result( + FlutterError( + code: "directory_access_denied", + message: "Failed to start security-scoped access.", + details: nil + ) + ) + return + } + + let accessId = UUID().uuidString + directoryAccessSessions[accessId] = url + var payload: [String: Any] = [ + "accessId": accessId, + "path": url.standardizedFileURL.path, + ] + if isStale, + let refreshedBookmark = try? url.bookmarkData( + options: [.withSecurityScope], + includingResourceValuesForKeys: nil, + relativeTo: nil + ) { + payload["bookmark"] = refreshedBookmark.base64EncodedString() + } + result(payload) + } catch { + result( + FlutterError( + code: "directory_access_failed", + message: error.localizedDescription, + details: nil + ) + ) + } + } + + private func stopDirectoryAccess(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + let arguments = call.arguments as? [String: Any] + let accessId = (arguments?["accessId"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !accessId.isEmpty else { + result(nil) + return + } + if let url = directoryAccessSessions.removeValue(forKey: accessId) { + url.stopAccessingSecurityScopedResource() + } + result(nil) + } + + private func initialDirectoryURL(for suggestedPath: String) -> URL? { + let trimmed = suggestedPath.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + return FileManager.default.homeDirectoryForCurrentUser + } + + var candidate = URL(fileURLWithPath: (trimmed as NSString).expandingTildeInPath) + var isDirectory: ObjCBool = false + while true { + if FileManager.default.fileExists(atPath: candidate.path, isDirectory: &isDirectory) { + return isDirectory.boolValue ? candidate.deletingLastPathComponent() : candidate.deletingLastPathComponent() + } + let parent = candidate.deletingLastPathComponent() + if parent.path == candidate.path || parent.path.isEmpty { + break + } + candidate = parent + } + return FileManager.default.homeDirectoryForCurrentUser + } } diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index 44d5d83c..39dcee08 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -6,6 +6,8 @@ com.apple.security.cs.allow-jit + com.apple.security.files.bookmarks.app-scope + com.apple.security.files.user-selected.read-only com.apple.security.network.client diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 625af03d..6abec674 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -4,6 +4,8 @@ com.apple.security.app-sandbox + com.apple.security.files.bookmarks.app-scope + com.apple.security.files.user-selected.read-only com.apple.security.network.client diff --git a/test/runtime/app_controller_thread_skills_suite.dart b/test/runtime/app_controller_thread_skills_suite.dart index f9f8792e..adb8887f 100644 --- a/test/runtime/app_controller_thread_skills_suite.dart +++ b/test/runtime/app_controller_thread_skills_suite.dart @@ -136,6 +136,76 @@ void main() { }, ); + test( + 'AppController hot reloads authorized skill directories from settings.yaml', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-skill-directory-hot-reload-', + ); + addTearDown(() async { + if (await tempDirectory.exists()) { + try { + await tempDirectory.delete(recursive: true); + } catch (_) {} + } + }); + final agentsRoot = Directory('${tempDirectory.path}/agents-skills'); + await _writeSkill( + agentsRoot, + 'browser', + skillName: 'Browser', + description: 'Browser tasks', + ); + + final store = await _createStore(tempDirectory.path); + final controller = AppController( + store: store, + availableSingleAgentProvidersOverride: const [ + SingleAgentProvider.codex, + ], + ); + addTearDown(controller.dispose); + await _waitFor(() => !controller.initializing); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.singleAgent, + ); + expect( + controller + .assistantImportedSkillsForSession(controller.currentSessionKey) + .where((skill) => skill.label == 'Browser'), + isEmpty, + ); + + final updatedSnapshot = + _singleAgentTestSettings(workspacePath: tempDirectory.path).copyWith( + authorizedSkillDirectories: [ + AuthorizedSkillDirectory(path: agentsRoot.path), + ], + ); + final settingsFile = File('${tempDirectory.path}/config/settings.yaml'); + await settingsFile.writeAsString( + encodeYamlDocument(updatedSnapshot.toJson()), + flush: true, + ); + + await _waitFor( + () => controller.authorizedSkillDirectories + .map((item) => item.path) + .contains(agentsRoot.path), + ); + await _waitFor( + () => controller + .assistantImportedSkillsForSession(controller.currentSessionKey) + .any((skill) => skill.label == 'Browser'), + ); + expect( + controller.authorizedSkillDirectories.map((item) => item.path), + [agentsRoot.path], + ); + }, + ); + test( 'AppController keeps thread-bound skills isolated and restores them after restart', () async { @@ -266,15 +336,21 @@ void main() { ); expect( - controller.assistantSelectedSkillsForSession(taskA).map((skill) => skill.label), + controller + .assistantSelectedSkillsForSession(taskA) + .map((skill) => skill.label), const ['PPT'], ); expect( - controller.assistantSelectedSkillsForSession(taskB).map((skill) => skill.label), + controller + .assistantSelectedSkillsForSession(taskB) + .map((skill) => skill.label), const ['WordX'], ); expect( - controller.assistantSelectedSkillsForSession(taskC).map((skill) => skill.label), + controller + .assistantSelectedSkillsForSession(taskC) + .map((skill) => skill.label), const ['Browser'], ); @@ -286,7 +362,9 @@ void main() { await restoredController.switchSession(taskA); await _waitFor( () => - restoredController.assistantImportedSkillsForSession(taskA).length == + restoredController + .assistantImportedSkillsForSession(taskA) + .length == 4, ); expect( @@ -298,7 +376,9 @@ void main() { await restoredController.switchSession(taskB); await _waitFor( () => - restoredController.assistantImportedSkillsForSession(taskB).length == + restoredController + .assistantImportedSkillsForSession(taskB) + .length == 4, ); expect( @@ -310,7 +390,9 @@ void main() { await restoredController.switchSession(taskC); await _waitFor( () => - restoredController.assistantImportedSkillsForSession(taskC).length == + restoredController + .assistantImportedSkillsForSession(taskC) + .length == 4, ); expect( @@ -457,7 +539,8 @@ void main() { ); final store = SecureConfigStore( enableSecureStorage: false, - databasePathResolver: () async => '${tempDirectory.path}/settings.sqlite3', + databasePathResolver: () async => + '${tempDirectory.path}/settings.sqlite3', fallbackDirectoryPathResolver: () async => tempDirectory.path, defaultSupportDirectoryPathResolver: () async => tempDirectory.path, ); @@ -491,10 +574,9 @@ void main() { addTearDown(controller.dispose); await _waitFor(() => !controller.initializing); await _waitFor( - () => - controller - .assistantImportedSkillsForSession(controller.currentSessionKey) - .any((item) => item.label == 'Workspace Only Skill'), + () => controller + .assistantImportedSkillsForSession(controller.currentSessionKey) + .any((item) => item.label == 'Workspace Only Skill'), ); expect( @@ -549,7 +631,8 @@ void main() { final store = SecureConfigStore( enableSecureStorage: false, - databasePathResolver: () async => '${tempDirectory.path}/settings.sqlite3', + databasePathResolver: () async => + '${tempDirectory.path}/settings.sqlite3', fallbackDirectoryPathResolver: () async => tempDirectory.path, defaultSupportDirectoryPathResolver: () async => tempDirectory.path, ); @@ -635,7 +718,8 @@ void main() { final store = SecureConfigStore( enableSecureStorage: false, - databasePathResolver: () async => '${tempDirectory.path}/settings.sqlite3', + databasePathResolver: () async => + '${tempDirectory.path}/settings.sqlite3', fallbackDirectoryPathResolver: () async => tempDirectory.path, defaultSupportDirectoryPathResolver: () async => tempDirectory.path, ); @@ -667,10 +751,9 @@ void main() { addTearDown(controller.dispose); await _waitFor(() => !controller.initializing); await _waitFor( - () => - controller - .assistantImportedSkillsForSession(controller.currentSessionKey) - .isNotEmpty, + () => controller + .assistantImportedSkillsForSession(controller.currentSessionKey) + .isNotEmpty, ); final sharedSkill = controller @@ -698,7 +781,8 @@ void main() { final store = SecureConfigStore( enableSecureStorage: false, - databasePathResolver: () async => '${tempDirectory.path}/settings.sqlite3', + databasePathResolver: () async => + '${tempDirectory.path}/settings.sqlite3', fallbackDirectoryPathResolver: () async => tempDirectory.path, defaultSupportDirectoryPathResolver: () async => tempDirectory.path, ); @@ -732,14 +816,15 @@ void main() { addTearDown(controller.dispose); await _waitFor(() => !controller.initializing); await _waitFor( - () => - controller.assistantImportedSkillsForSession( - controller.currentSessionKey, - ).isEmpty, + () => controller + .assistantImportedSkillsForSession(controller.currentSessionKey) + .isEmpty, ); expect( - controller.assistantImportedSkillsForSession(controller.currentSessionKey), + controller.assistantImportedSkillsForSession( + controller.currentSessionKey, + ), isEmpty, ); }, diff --git a/test/runtime/secure_config_store_suite.dart b/test/runtime/secure_config_store_suite.dart index c49934c4..b270cb91 100644 --- a/test/runtime/secure_config_store_suite.dart +++ b/test/runtime/secure_config_store_suite.dart @@ -827,6 +827,26 @@ void main() { expect(decoded.assistantLastSessionKey, 'draft:session-1'); }); + test('SettingsSnapshot encodes and decodes authorizedSkillDirectories', () { + final snapshot = SettingsSnapshot.defaults().copyWith( + authorizedSkillDirectories: const [ + AuthorizedSkillDirectory(path: '/etc/skills'), + AuthorizedSkillDirectory( + path: '/Users/test/.codex/skills', + bookmark: 'bookmark-data', + ), + ], + ); + + final decoded = SettingsSnapshot.fromJsonString(snapshot.toJsonString()); + + expect( + decoded.authorizedSkillDirectories.map((item) => item.path), + const ['/Users/test/.codex/skills', '/etc/skills'], + ); + expect(decoded.authorizedSkillDirectories.first.bookmark, 'bookmark-data'); + }); + test( 'AssistantThreadRecord keeps compatibility with legacy json payloads', () {