feat(settings): add authorized skills directory authorization
This commit is contained in:
parent
c2a8cc0b3a
commit
ea2edfdc02
@ -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<String>? singleAgentLocalSkillScanRoots,
|
||||
List<SingleAgentProvider>? availableSingleAgentProvidersOverride,
|
||||
ArisBundleRepository? arisBundleRepository,
|
||||
@ -141,10 +145,10 @@ class AppController extends ChangeNotifier {
|
||||
_tasksController = DerivedTasksController();
|
||||
_desktopPlatformService =
|
||||
desktopPlatformService ?? createDesktopPlatformService();
|
||||
_singleAgentLocalSkillScanRootOverrides =
|
||||
(singleAgentLocalSkillScanRoots ??
|
||||
(_isFlutterTestEnvironment ? const <String>[] : 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<String>? _singleAgentLocalSkillScanRootOverrides;
|
||||
late final GatewayAcpClient _gatewayAcpClient;
|
||||
late final DirectSingleAgentAppServerClient _singleAgentAppServerClient;
|
||||
@ -247,15 +252,17 @@ class AppController extends ChangeNotifier {
|
||||
String? _bootstrapError;
|
||||
StreamSubscription<GatewayPushEvent>? _runtimeEventsSubscription;
|
||||
bool _disposed = false;
|
||||
static bool get _isFlutterTestEnvironment =>
|
||||
Platform.environment.containsKey('FLUTTER_TEST');
|
||||
SettingsSnapshot _lastObservedSettingsSnapshot = SettingsSnapshot.defaults();
|
||||
Future<void> _assistantThreadPersistQueue = Future<void>.value();
|
||||
Future<void> _settingsObservationQueue = Future<void>.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<AuthorizedSkillDirectory> get authorizedSkillDirectories =>
|
||||
settings.authorizedSkillDirectories;
|
||||
List<String> 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<AuthorizedSkillDirectory?> authorizeSkillDirectory({
|
||||
String suggestedPath = '',
|
||||
}) {
|
||||
return _skillDirectoryAccessService.authorizeDirectory(
|
||||
suggestedPath: suggestedPath,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> saveAuthorizedSkillDirectories(
|
||||
List<AuthorizedSkillDirectory> 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<void> 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<void> _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 = <String, AssistantThreadSkillEntry>{};
|
||||
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<void> _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();
|
||||
}
|
||||
|
||||
@ -106,6 +106,17 @@ class AppController extends ChangeNotifier {
|
||||
SettingsSnapshot get settings => _settings;
|
||||
SettingsSnapshot get settingsDraft =>
|
||||
_settingsDraftInitialized ? _settingsDraft : _settings;
|
||||
bool get supportsSkillDirectoryAuthorization => false;
|
||||
List<AuthorizedSkillDirectory> get authorizedSkillDirectories =>
|
||||
_settings.authorizedSkillDirectories;
|
||||
List<String> get recommendedAuthorizedSkillDirectoryPaths => const <String>[
|
||||
'/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<AuthorizedSkillDirectory?> authorizeSkillDirectory({
|
||||
String suggestedPath = '',
|
||||
}) async {
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> saveAuthorizedSkillDirectories(
|
||||
List<AuthorizedSkillDirectory> 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);
|
||||
}
|
||||
|
||||
@ -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<SettingsPage> {
|
||||
_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<SettingsPage> {
|
||||
'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<SettingsPage> {
|
||||
'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<SettingsPage> {
|
||||
_GatewayIntegrationSubTab.acp => <Widget>[
|
||||
_buildExternalAcpEndpointManager(context, controller, settings),
|
||||
],
|
||||
_GatewayIntegrationSubTab.skills => <Widget>[
|
||||
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 }
|
||||
|
||||
|
||||
445
lib/features/settings/skill_directory_authorization_card.dart
Normal file
445
lib/features/settings/skill_directory_authorization_card.dart
Normal file
@ -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<SkillDirectoryAuthorizationCard> createState() =>
|
||||
_SkillDirectoryAuthorizationCardState();
|
||||
}
|
||||
|
||||
class _SkillDirectoryAuthorizationCardState
|
||||
extends State<SkillDirectoryAuthorizationCard> {
|
||||
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<void> 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<AuthorizedSkillDirectory> directories,
|
||||
String candidatePath, {
|
||||
required String homeDirectory,
|
||||
}) {
|
||||
for (final directory in directories) {
|
||||
if (_matchesResolvedPath(
|
||||
directory.path,
|
||||
candidatePath,
|
||||
homeDirectory: homeDirectory,
|
||||
)) {
|
||||
return directory;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _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: <AuthorizedSkillDirectory>[
|
||||
...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<void> _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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -13,6 +13,49 @@ void debugOverridePersistentSupportRoot(String? path) {
|
||||
: normalizeStoreDirectoryPath(trimmed);
|
||||
}
|
||||
|
||||
String? defaultUserSettingsRootPath({
|
||||
Map<String, String>? 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<String, String>? 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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -13,8 +13,14 @@ class SettingsController extends ChangeNotifier {
|
||||
|
||||
final SecureConfigStore _store;
|
||||
bool _disposed = false;
|
||||
final List<StreamSubscription<FileSystemEvent>> _settingsWatchSubscriptions =
|
||||
<StreamSubscription<FileSystemEvent>>[];
|
||||
Timer? _settingsReloadDebounce;
|
||||
Timer? _settingsPollTimer;
|
||||
|
||||
SettingsSnapshot _snapshot = SettingsSnapshot.defaults();
|
||||
String _lastSnapshotJson = SettingsSnapshot.defaults().toJsonString();
|
||||
String _lastSettingsFileStamp = '';
|
||||
Map<String, String> _secureRefs = const <String, String>{};
|
||||
List<SecretAuditEntry> _auditTrail = const <SecretAuditEntry>[];
|
||||
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<void> 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<void> saveSnapshot(SettingsSnapshot snapshot) async {
|
||||
_snapshot = snapshot;
|
||||
_lastSnapshotJson = _snapshot.toJsonString();
|
||||
await _store.saveSettingsSnapshot(snapshot);
|
||||
await _refreshSettingsFileStamp();
|
||||
await _reloadDerivedState();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> 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<void> _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<void> _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<void> _pollSettingsFileChanges() async {
|
||||
if (_disposed) {
|
||||
return;
|
||||
}
|
||||
final previousStamp = _lastSettingsFileStamp;
|
||||
final nextStamp = await _computeSettingsFileStamp();
|
||||
if (nextStamp == previousStamp) {
|
||||
return;
|
||||
}
|
||||
await _reloadSettingsFromDiskIfChanged();
|
||||
}
|
||||
|
||||
Future<void> _refreshSettingsFileStamp() async {
|
||||
_lastSettingsFileStamp = await _computeSettingsFileStamp();
|
||||
}
|
||||
|
||||
Future<String> _resolveStableSettingsFileStamp() async {
|
||||
var current = await _computeSettingsFileStamp();
|
||||
for (var attempt = 0; attempt < 4; attempt++) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 120));
|
||||
final next = await _computeSettingsFileStamp();
|
||||
if (next == current) {
|
||||
return next;
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
Future<String> _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 {
|
||||
|
||||
@ -236,6 +236,65 @@ List<ExternalAcpEndpointProfile> 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<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'path': path,
|
||||
if (bookmark.trim().isNotEmpty) 'bookmark': bookmark,
|
||||
};
|
||||
}
|
||||
|
||||
factory AuthorizedSkillDirectory.fromJson(Map<String, dynamic> json) {
|
||||
return AuthorizedSkillDirectory(
|
||||
path: normalizeAuthorizedSkillDirectoryPath(
|
||||
json['path']?.toString() ?? '',
|
||||
),
|
||||
bookmark: json['bookmark']?.toString().trim() ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<AuthorizedSkillDirectory> normalizeAuthorizedSkillDirectories({
|
||||
Iterable<AuthorizedSkillDirectory>? directories,
|
||||
}) {
|
||||
final incoming =
|
||||
directories?.toList(growable: false) ??
|
||||
const <AuthorizedSkillDirectory>[];
|
||||
final normalized = <AuthorizedSkillDirectory>[];
|
||||
final seen = <String>{};
|
||||
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<AuthorizedSkillDirectory>.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<GatewayConnectionProfile> gatewayProfiles;
|
||||
final List<ExternalAcpEndpointProfile> externalAcpEndpoints;
|
||||
final List<AuthorizedSkillDirectory> 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<GatewayConnectionProfile>? gatewayProfiles,
|
||||
List<ExternalAcpEndpointProfile>? externalAcpEndpoints,
|
||||
List<AuthorizedSkillDirectory>? 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 <Object>[])
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(item) => AuthorizedSkillDirectory.fromJson(
|
||||
item.cast<String, dynamic>(),
|
||||
),
|
||||
),
|
||||
);
|
||||
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<String, dynamic>() ?? const {},
|
||||
),
|
||||
|
||||
@ -55,10 +55,26 @@ class SecureConfigStore {
|
||||
return _settingsStore.loadSettingsSnapshot();
|
||||
}
|
||||
|
||||
Future<SettingsSnapshot> reloadSettingsSnapshot() {
|
||||
return _settingsStore.reloadSettingsSnapshot();
|
||||
}
|
||||
|
||||
Future<SettingsSnapshotReloadResult> reloadSettingsSnapshotResult() {
|
||||
return _settingsStore.reloadSettingsSnapshotResult();
|
||||
}
|
||||
|
||||
Future<void> saveSettingsSnapshot(SettingsSnapshot snapshot) {
|
||||
return _settingsStore.saveSettingsSnapshot(snapshot);
|
||||
}
|
||||
|
||||
Future<List<File>> resolvedSettingsFiles() {
|
||||
return _settingsStore.resolvedSettingsFiles();
|
||||
}
|
||||
|
||||
Future<List<Directory>> resolvedSettingsWatchDirectories() {
|
||||
return _settingsStore.resolvedSettingsWatchDirectories();
|
||||
}
|
||||
|
||||
Future<List<AssistantThreadRecord>> loadAssistantThreadRecords() {
|
||||
return _settingsStore.loadAssistantThreadRecords();
|
||||
}
|
||||
|
||||
@ -8,6 +8,20 @@ import 'runtime_models.dart';
|
||||
typedef SecureConfigDatabaseOpener =
|
||||
FutureOr<Object?> 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<String?> 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<File> _settingsFiles = const <File>[];
|
||||
List<Directory> _settingsWatchDirectories = const <Directory>[];
|
||||
SettingsSnapshot _settingsSnapshot = SettingsSnapshot.defaults();
|
||||
List<AssistantThreadRecord> _threadRecords = const <AssistantThreadRecord>[];
|
||||
List<SecretAuditEntry> _auditTrail = const <SecretAuditEntry>[];
|
||||
@ -49,8 +69,14 @@ class SettingsStore {
|
||||
_initialized = true;
|
||||
try {
|
||||
_layout = await _layoutResolver.resolve();
|
||||
_settingsFiles = _resolveSettingsFiles(_layout!);
|
||||
_settingsWatchDirectories = _resolveSettingsWatchDirectories(
|
||||
_settingsFiles,
|
||||
);
|
||||
} catch (_) {
|
||||
_layout = null;
|
||||
_settingsFiles = const <File>[];
|
||||
_settingsWatchDirectories = const <Directory>[];
|
||||
return;
|
||||
}
|
||||
_settingsSnapshot = await _readSettingsSnapshot();
|
||||
@ -63,6 +89,37 @@ class SettingsStore {
|
||||
return _settingsSnapshot;
|
||||
}
|
||||
|
||||
Future<SettingsSnapshot> reloadSettingsSnapshot() async {
|
||||
final result = await reloadSettingsSnapshotResult();
|
||||
return result.snapshot;
|
||||
}
|
||||
|
||||
Future<SettingsSnapshotReloadResult> 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<List<File>> resolvedSettingsFiles() async {
|
||||
await initialize();
|
||||
return List<File>.from(_settingsFiles);
|
||||
}
|
||||
|
||||
Future<List<Directory>> resolvedSettingsWatchDirectories() async {
|
||||
await initialize();
|
||||
return List<Directory>.from(_settingsWatchDirectories);
|
||||
}
|
||||
|
||||
Future<void> 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
|
||||
? <File>[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
|
||||
? <File>[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<SettingsSnapshot> _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<SettingsSnapshotReloadResult> _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<String, dynamic>());
|
||||
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<String, dynamic>(),
|
||||
),
|
||||
status: SettingsSnapshotReloadStatus.applied,
|
||||
);
|
||||
}
|
||||
sawInvalidFile = true;
|
||||
} catch (_) {
|
||||
sawInvalidFile = true;
|
||||
}
|
||||
}
|
||||
return SettingsSnapshotReloadResult(
|
||||
snapshot: SettingsSnapshot.defaults(),
|
||||
status: sawExistingFile && sawInvalidFile
|
||||
? SettingsSnapshotReloadStatus.invalid
|
||||
: SettingsSnapshotReloadStatus.applied,
|
||||
);
|
||||
}
|
||||
|
||||
List<File> _resolveSettingsFiles(StoreLayout layout) {
|
||||
final resolved = <File>[];
|
||||
final seen = <String>{};
|
||||
|
||||
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<File>.unmodifiable(resolved);
|
||||
}
|
||||
|
||||
List<Directory> _resolveSettingsWatchDirectories(List<File> files) {
|
||||
final directories = <Directory>[];
|
||||
final seen = <String>{};
|
||||
for (final file in files) {
|
||||
final path = file.parent.path.trim();
|
||||
if (path.isEmpty || !seen.add(path)) {
|
||||
continue;
|
||||
}
|
||||
directories.add(Directory(path));
|
||||
}
|
||||
return List<Directory>.unmodifiable(directories);
|
||||
}
|
||||
|
||||
Future<List<AssistantThreadRecord>> _readAssistantThreadRecords() async {
|
||||
|
||||
193
lib/runtime/skill_directory_access.dart
Normal file
193
lib/runtime/skill_directory_access.dart
Normal file
@ -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<AuthorizedSkillDirectory?> authorizeDirectory({
|
||||
String suggestedPath = '',
|
||||
});
|
||||
|
||||
Future<SkillDirectoryAccessHandle?> openDirectory(
|
||||
AuthorizedSkillDirectory directory,
|
||||
);
|
||||
}
|
||||
|
||||
class SkillDirectoryAccessHandle {
|
||||
SkillDirectoryAccessHandle({
|
||||
required this.path,
|
||||
required Future<void> Function() onClose,
|
||||
this.refreshedBookmark = '',
|
||||
}) : _onClose = onClose;
|
||||
|
||||
final String path;
|
||||
final String refreshedBookmark;
|
||||
final Future<void> Function() _onClose;
|
||||
|
||||
Future<void> 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<AuthorizedSkillDirectory?> authorizeDirectory({
|
||||
String suggestedPath = '',
|
||||
}) async {
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SkillDirectoryAccessHandle?> openDirectory(
|
||||
AuthorizedSkillDirectory directory,
|
||||
) async {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class FileSelectorSkillDirectoryAccessService
|
||||
implements SkillDirectoryAccessService {
|
||||
@override
|
||||
bool get isSupported => true;
|
||||
|
||||
@override
|
||||
Future<AuthorizedSkillDirectory?> 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<SkillDirectoryAccessHandle?> 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<AuthorizedSkillDirectory?> authorizeDirectory({
|
||||
String suggestedPath = '',
|
||||
}) async {
|
||||
final response = await _channel.invokeMapMethod<String, dynamic>(
|
||||
'authorizeDirectory',
|
||||
<String, dynamic>{'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<SkillDirectoryAccessHandle?> 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<String, dynamic>(
|
||||
'startDirectoryAccess',
|
||||
<String, dynamic>{'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<void>(
|
||||
'stopDirectoryAccess',
|
||||
<String, dynamic>{'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;
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,8 @@
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.bookmarks.app-scope</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
|
||||
@ -4,6 +4,8 @@
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.bookmarks.app-scope</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
|
||||
@ -136,6 +136,76 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController hot reloads authorized skill directories from settings.yaml',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
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>[
|
||||
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>[
|
||||
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),
|
||||
<String>[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 <String>['PPT'],
|
||||
);
|
||||
expect(
|
||||
controller.assistantSelectedSkillsForSession(taskB).map((skill) => skill.label),
|
||||
controller
|
||||
.assistantSelectedSkillsForSession(taskB)
|
||||
.map((skill) => skill.label),
|
||||
const <String>['WordX'],
|
||||
);
|
||||
expect(
|
||||
controller.assistantSelectedSkillsForSession(taskC).map((skill) => skill.label),
|
||||
controller
|
||||
.assistantSelectedSkillsForSession(taskC)
|
||||
.map((skill) => skill.label),
|
||||
const <String>['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,
|
||||
);
|
||||
},
|
||||
|
||||
@ -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>[
|
||||
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 <String>['/Users/test/.codex/skills', '/etc/skills'],
|
||||
);
|
||||
expect(decoded.authorizedSkillDirectories.first.bookmark, 'bookmark-data');
|
||||
});
|
||||
|
||||
test(
|
||||
'AssistantThreadRecord keeps compatibility with legacy json payloads',
|
||||
() {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user