refactor: remove stale runtime fallbacks
This commit is contained in:
parent
1b3a3b5c4a
commit
60ed369df7
@ -12,7 +12,6 @@ import '../i18n/app_language.dart';
|
||||
import '../models/app_models.dart';
|
||||
import '../runtime/device_identity_store.dart';
|
||||
import '../runtime/file_store_support.dart';
|
||||
import '../runtime/go_core.dart';
|
||||
import '../runtime/runtime_bootstrap.dart';
|
||||
import '../runtime/desktop_platform_service.dart';
|
||||
import '../runtime/gateway_runtime.dart';
|
||||
|
||||
@ -12,7 +12,6 @@ import '../i18n/app_language.dart';
|
||||
import '../models/app_models.dart';
|
||||
import '../runtime/device_identity_store.dart';
|
||||
|
||||
import '../runtime/go_core.dart';
|
||||
import '../runtime/runtime_bootstrap.dart';
|
||||
import '../runtime/desktop_platform_service.dart';
|
||||
import '../runtime/gateway_runtime.dart';
|
||||
|
||||
@ -12,7 +12,6 @@ import '../i18n/app_language.dart';
|
||||
import '../models/app_models.dart';
|
||||
import '../runtime/device_identity_store.dart';
|
||||
|
||||
import '../runtime/go_core.dart';
|
||||
import '../runtime/runtime_bootstrap.dart';
|
||||
import '../runtime/desktop_platform_service.dart';
|
||||
import '../runtime/gateway_runtime.dart';
|
||||
|
||||
@ -12,7 +12,6 @@ import '../i18n/app_language.dart';
|
||||
import '../models/app_models.dart';
|
||||
import '../runtime/device_identity_store.dart';
|
||||
|
||||
import '../runtime/go_core.dart';
|
||||
import '../runtime/runtime_bootstrap.dart';
|
||||
import '../runtime/desktop_platform_service.dart';
|
||||
import '../runtime/gateway_runtime.dart';
|
||||
|
||||
@ -12,7 +12,6 @@ import '../i18n/app_language.dart';
|
||||
import '../models/app_models.dart';
|
||||
import '../runtime/device_identity_store.dart';
|
||||
|
||||
import '../runtime/go_core.dart';
|
||||
import '../runtime/runtime_bootstrap.dart';
|
||||
import '../runtime/desktop_platform_service.dart';
|
||||
import '../runtime/gateway_runtime.dart';
|
||||
|
||||
@ -14,7 +14,6 @@ import '../i18n/app_language.dart';
|
||||
import '../models/app_models.dart';
|
||||
import '../runtime/device_identity_store.dart';
|
||||
|
||||
import '../runtime/go_core.dart';
|
||||
import '../runtime/acp_endpoint_paths.dart';
|
||||
import '../runtime/runtime_bootstrap.dart';
|
||||
import '../runtime/desktop_platform_service.dart';
|
||||
|
||||
@ -12,7 +12,6 @@ import '../i18n/app_language.dart';
|
||||
import '../models/app_models.dart';
|
||||
import '../runtime/device_identity_store.dart';
|
||||
|
||||
import '../runtime/go_core.dart';
|
||||
import '../runtime/runtime_bootstrap.dart';
|
||||
import '../runtime/desktop_platform_service.dart';
|
||||
import '../runtime/gateway_runtime.dart';
|
||||
|
||||
@ -12,7 +12,6 @@ import '../i18n/app_language.dart';
|
||||
import '../models/app_models.dart';
|
||||
import '../runtime/device_identity_store.dart';
|
||||
|
||||
import '../runtime/go_core.dart';
|
||||
import '../runtime/runtime_bootstrap.dart';
|
||||
import '../runtime/desktop_platform_service.dart';
|
||||
import '../runtime/gateway_runtime.dart';
|
||||
|
||||
@ -12,7 +12,6 @@ import '../i18n/app_language.dart';
|
||||
import '../models/app_models.dart';
|
||||
import '../runtime/device_identity_store.dart';
|
||||
|
||||
import '../runtime/go_core.dart';
|
||||
import '../runtime/runtime_bootstrap.dart';
|
||||
import '../runtime/desktop_platform_service.dart';
|
||||
import '../runtime/gateway_runtime.dart';
|
||||
|
||||
@ -13,7 +13,6 @@ import '../i18n/app_language.dart';
|
||||
import '../models/app_models.dart';
|
||||
import '../runtime/device_identity_store.dart';
|
||||
|
||||
import '../runtime/go_core.dart';
|
||||
import '../runtime/runtime_bootstrap.dart';
|
||||
import '../runtime/desktop_platform_service.dart';
|
||||
import '../runtime/gateway_runtime.dart';
|
||||
@ -97,10 +96,14 @@ extension AppControllerDesktopThreadActions on AppController {
|
||||
}
|
||||
try {
|
||||
await runtimeInternal.health();
|
||||
} catch (_) {}
|
||||
} catch (error) {
|
||||
debugPrint('Gateway health refresh failed: $error');
|
||||
}
|
||||
try {
|
||||
await runtimeInternal.status();
|
||||
} catch (_) {}
|
||||
} catch (error) {
|
||||
debugPrint('Gateway status refresh failed: $error');
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@ -465,12 +468,20 @@ extension AppControllerDesktopThreadActions on AppController {
|
||||
final capturedLocalAttachments = List<CollaborationAttachment>.unmodifiable(
|
||||
localAttachments,
|
||||
);
|
||||
final taskMetadata = Map<String, dynamic>.unmodifiable(dispatch.metadata);
|
||||
final executionWorkingDirectory = gatewayExecutionWorkingDirectoryInternal(
|
||||
target: currentTarget,
|
||||
workingDirectory: workingDirectory,
|
||||
remoteWorkingDirectoryHint: remoteWorkingDirectoryHint,
|
||||
);
|
||||
final taskMetadata = Map<String, dynamic>.unmodifiable(
|
||||
gatewayTaskMetadataWithArtifactContractInternal(
|
||||
baseMetadata: dispatch.metadata,
|
||||
sessionKey: normalizedSessionKey,
|
||||
localWorkingDirectory: workingDirectory,
|
||||
executionWorkingDirectory: executionWorkingDirectory,
|
||||
remoteWorkingDirectoryHint: remoteWorkingDirectoryHint,
|
||||
),
|
||||
);
|
||||
if (usesOpenClawGatewayQueueInternal(currentTarget, provider)) {
|
||||
await enqueueOpenClawGatewayTurnInternal(
|
||||
OpenClawGatewayQueuedTurnInternal(
|
||||
@ -924,12 +935,57 @@ extension AppControllerDesktopThreadActions on AppController {
|
||||
'7. Files listed in taskInputAttachments already belong to this TaskThread; reuse them from the task context and do not ask the user to upload them again.',
|
||||
)
|
||||
..writeln();
|
||||
if (target.isGateway) {
|
||||
buffer
|
||||
..writeln('XWorkmate task artifact contract:')
|
||||
..writeln(
|
||||
'- The remote runtime owns final-deliverable detection; do not rely on local task classification.',
|
||||
)
|
||||
..writeln(
|
||||
'- If this request needs files, export the final deliverables through the current XWorkmate task artifact scope before final response.',
|
||||
)
|
||||
..writeln(
|
||||
'- A textual download/path claim is not a deliverable unless the file has been exported into the current task artifact scope.',
|
||||
)
|
||||
..writeln(
|
||||
'- Do not reuse artifacts from previous sessions, previous runs, or global OpenClaw workspaces.',
|
||||
)
|
||||
..writeln();
|
||||
}
|
||||
buffer
|
||||
..writeln('User request:')
|
||||
..write(requestText);
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
Map<String, dynamic> gatewayTaskMetadataWithArtifactContractInternal({
|
||||
required Map<String, dynamic> baseMetadata,
|
||||
required String sessionKey,
|
||||
required String localWorkingDirectory,
|
||||
required String executionWorkingDirectory,
|
||||
required String remoteWorkingDirectoryHint,
|
||||
}) {
|
||||
final localWorkspace = localWorkingDirectory.trim();
|
||||
final executionWorkspace = executionWorkingDirectory.trim();
|
||||
final remoteHint = remoteWorkingDirectoryHint.trim();
|
||||
return <String, dynamic>{
|
||||
...baseMetadata,
|
||||
'xworkmateTaskArtifactContract': <String, dynamic>{
|
||||
'version': 1,
|
||||
'sessionKey': sessionKey,
|
||||
'scopeKind': 'task',
|
||||
'finalDeliverableDetection': 'remote-runtime',
|
||||
'requiresExportBeforeFinalResponse': true,
|
||||
'rejectTextOnlyFileClaims': true,
|
||||
'currentTaskWorkspace': executionWorkspace.isNotEmpty
|
||||
? executionWorkspace
|
||||
: (remoteHint.isNotEmpty ? remoteHint : localWorkspace),
|
||||
if (localWorkspace.isNotEmpty) 'localWorkspace': localWorkspace,
|
||||
if (remoteHint.isNotEmpty) 'remoteWorkspaceHint': remoteHint,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
bool usesOpenClawGatewayQueueInternal(
|
||||
AssistantExecutionTarget target,
|
||||
SingleAgentProvider provider,
|
||||
|
||||
@ -12,7 +12,6 @@ import '../i18n/app_language.dart';
|
||||
import '../models/app_models.dart';
|
||||
import '../runtime/device_identity_store.dart';
|
||||
|
||||
import '../runtime/go_core.dart';
|
||||
import '../runtime/runtime_bootstrap.dart';
|
||||
import '../runtime/desktop_platform_service.dart';
|
||||
import '../runtime/gateway_runtime.dart';
|
||||
|
||||
@ -12,7 +12,6 @@ import '../i18n/app_language.dart';
|
||||
import '../models/app_models.dart';
|
||||
import '../runtime/device_identity_store.dart';
|
||||
|
||||
import '../runtime/go_core.dart';
|
||||
import '../runtime/runtime_bootstrap.dart';
|
||||
import '../runtime/desktop_platform_service.dart';
|
||||
import '../runtime/gateway_runtime.dart';
|
||||
|
||||
@ -12,7 +12,6 @@ import '../i18n/app_language.dart';
|
||||
import '../models/app_models.dart';
|
||||
import '../runtime/device_identity_store.dart';
|
||||
|
||||
import '../runtime/go_core.dart';
|
||||
import '../runtime/runtime_bootstrap.dart';
|
||||
import '../runtime/desktop_platform_service.dart';
|
||||
import '../runtime/gateway_runtime.dart';
|
||||
@ -66,7 +65,8 @@ Future<void> openOnlineWorkspaceThreadSessionInternal(
|
||||
if (Platform.isLinux) {
|
||||
await Process.run('xdg-open', [url]);
|
||||
}
|
||||
} catch (_) {
|
||||
} catch (error) {
|
||||
debugPrint('Open collaboration URL failed: $error');
|
||||
// Best effort only. Do not surface a blocking error from a convenience link.
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,7 +12,6 @@ import '../i18n/app_language.dart';
|
||||
import '../models/app_models.dart';
|
||||
import '../runtime/device_identity_store.dart';
|
||||
|
||||
import '../runtime/go_core.dart';
|
||||
import '../runtime/runtime_bootstrap.dart';
|
||||
import '../runtime/desktop_platform_service.dart';
|
||||
import '../runtime/gateway_runtime.dart';
|
||||
|
||||
@ -12,7 +12,6 @@ import '../i18n/app_language.dart';
|
||||
import '../models/app_models.dart';
|
||||
import '../runtime/device_identity_store.dart';
|
||||
|
||||
import '../runtime/go_core.dart';
|
||||
import '../runtime/runtime_bootstrap.dart';
|
||||
import '../runtime/desktop_platform_service.dart';
|
||||
import '../runtime/gateway_runtime.dart';
|
||||
|
||||
@ -156,7 +156,8 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal {
|
||||
selectedSkillLabels: selectedSkillLabels,
|
||||
);
|
||||
clearComposerDraftForSessionInternal(submittedSessionKey);
|
||||
} catch (_) {
|
||||
} catch (error) {
|
||||
debugPrint('Assistant task submission failed: $error');
|
||||
if (!mounted) {
|
||||
rethrow;
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
import '../../app/app_controller.dart';
|
||||
|
||||
@ -30,6 +31,22 @@ Map<String, Object?> desktopOfferParams({
|
||||
};
|
||||
}
|
||||
|
||||
Future<MediaStream?> desktopRemoteVideoStreamForTrack(
|
||||
RTCTrackEvent event, {
|
||||
required Future<MediaStream> Function(String label) createFallbackStream,
|
||||
}) async {
|
||||
if (event.track.kind != 'video') {
|
||||
return null;
|
||||
}
|
||||
if (event.streams.isNotEmpty) {
|
||||
return event.streams.first;
|
||||
}
|
||||
|
||||
final stream = await createFallbackStream('xworkmate-remote-desktop');
|
||||
await stream.addTrack(event.track, addToNative: false);
|
||||
return stream;
|
||||
}
|
||||
|
||||
class DesktopClient {
|
||||
DesktopClient({required this.controller, required this.sessionId});
|
||||
|
||||
@ -38,7 +55,6 @@ class DesktopClient {
|
||||
|
||||
RTCPeerConnection? _peerConnection;
|
||||
RTCDataChannel? _dataChannel;
|
||||
MediaStream? _remoteStream;
|
||||
|
||||
final StreamController<MediaStream> _streamController =
|
||||
StreamController<MediaStream>.broadcast();
|
||||
@ -76,14 +92,18 @@ class DesktopClient {
|
||||
|
||||
_peerConnection = await createPeerConnection(config);
|
||||
|
||||
// Listen for remote streams
|
||||
// Listen for remote video tracks. Some Unified Plan servers send
|
||||
// streamless tracks, so synthesize a stream for RTCVideoView when needed.
|
||||
_peerConnection!.onTrack = (event) {
|
||||
if (event.track.kind == 'video') {
|
||||
if (event.streams.isNotEmpty) {
|
||||
_remoteStream = event.streams.first;
|
||||
_streamController.add(_remoteStream!);
|
||||
unawaited(() async {
|
||||
final stream = await desktopRemoteVideoStreamForTrack(
|
||||
event,
|
||||
createFallbackStream: createLocalMediaStream,
|
||||
);
|
||||
if (stream != null) {
|
||||
_streamController.add(stream);
|
||||
}
|
||||
}
|
||||
}());
|
||||
};
|
||||
|
||||
_peerConnection!.onConnectionState = (state) {
|
||||
@ -187,7 +207,9 @@ class DesktopClient {
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (_) {}
|
||||
} catch (error) {
|
||||
debugPrint('Desktop ICE candidate send failed: $error');
|
||||
}
|
||||
}
|
||||
|
||||
void sendInput(Map<String, dynamic> event) {
|
||||
@ -205,13 +227,14 @@ class DesktopClient {
|
||||
method: 'xworkmate.desktop.close',
|
||||
params: {'sessionId': sessionId},
|
||||
);
|
||||
} catch (_) {}
|
||||
} catch (error) {
|
||||
debugPrint('Desktop close request failed: $error');
|
||||
}
|
||||
|
||||
await _dataChannel?.close();
|
||||
await _peerConnection?.close();
|
||||
_dataChannel = null;
|
||||
_peerConnection = null;
|
||||
_remoteStream = null;
|
||||
_stateController.add('disconnected');
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import 'dart:async';
|
||||
|
||||
/// Codex sandbox mode for controlling file system access.
|
||||
enum CodexSandboxMode {
|
||||
@ -274,70 +273,6 @@ enum CodexConnectionState {
|
||||
/// Codex App Server RPC client.
|
||||
class CodexRuntime {}
|
||||
|
||||
List<Map<String, dynamic>> _decodeModelListResponse(
|
||||
Map<String, dynamic> result,
|
||||
) {
|
||||
final rawModels = <Object?>[
|
||||
...switch (result['models']) {
|
||||
final List<Object?> items => items,
|
||||
_ => const <Object?>[],
|
||||
},
|
||||
if (switch (result['models']) {
|
||||
final List<Object?> items => items.isEmpty,
|
||||
_ => true,
|
||||
})
|
||||
...switch (result['data']) {
|
||||
final List<Object?> items => items,
|
||||
_ => const <Object?>[],
|
||||
},
|
||||
];
|
||||
final seen = <String>{};
|
||||
final items = <Map<String, dynamic>>[];
|
||||
for (final item in rawModels) {
|
||||
if (item is! Map) {
|
||||
continue;
|
||||
}
|
||||
final model = item.cast<String, dynamic>();
|
||||
final rawId = model['id'] ?? model['name'];
|
||||
final id = rawId is String ? rawId.trim() : '';
|
||||
if (id.isEmpty || !seen.add(id)) {
|
||||
continue;
|
||||
}
|
||||
items.add(model);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
Object _normalizeModelListError(Object error) {
|
||||
if (error is TimeoutException) {
|
||||
return TimeoutException('Codex model refresh timed out');
|
||||
}
|
||||
if (error is CodexRpcError) {
|
||||
final message = error.message.trim();
|
||||
final lower = message.toLowerCase();
|
||||
if (lower.contains('cloudflare') || lower.contains('403 forbidden')) {
|
||||
return CodexRpcError(
|
||||
code: error.code,
|
||||
message: 'Codex model refresh blocked by Cloudflare (403)',
|
||||
data: error.data,
|
||||
);
|
||||
}
|
||||
if (lower.contains('timeout waiting for child process to exit')) {
|
||||
return TimeoutException(
|
||||
'Codex model refresh timed out waiting for child process exit',
|
||||
);
|
||||
}
|
||||
if (lower.contains('missing field `models`')) {
|
||||
return CodexRpcError(
|
||||
code: error.code,
|
||||
message: 'Codex model list payload used an unsupported schema',
|
||||
data: error.data,
|
||||
);
|
||||
}
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
class CodexLaunchConfiguration {
|
||||
const CodexLaunchConfiguration({
|
||||
required this.executable,
|
||||
|
||||
@ -14,14 +14,3 @@ bool shouldBlockEmbeddedAgentLaunch({
|
||||
enabled: enabled,
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper for Go core launch blocking check.
|
||||
bool shouldBlockGoCoreLaunch({
|
||||
required bool isAppleHost,
|
||||
bool? enabled,
|
||||
}) {
|
||||
return shouldBlockEmbeddedAgentLaunch(
|
||||
isAppleHost: isAppleHost,
|
||||
enabled: enabled,
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:yaml/yaml.dart';
|
||||
|
||||
@ -143,19 +144,7 @@ class StoreLayoutResolver {
|
||||
await _resolvePath(_supportRootPathResolver) ??
|
||||
await _defaultSupportRootPath();
|
||||
if (supportRootPath == null) {
|
||||
// Fallback to a temporary directory instead of failing fast with an error.
|
||||
// This ensures the app remains usable in "memory-only" or "ephemeral" mode.
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-fallback-',
|
||||
);
|
||||
final layout = StoreLayout(
|
||||
rootDirectory: tempDir,
|
||||
configDirectory: await ensureDirectory('${tempDir.path}/config'),
|
||||
tasksDirectory: await ensureDirectory('${tempDir.path}/tasks'),
|
||||
secretDirectory: await ensureDirectory('${tempDir.path}/secrets'),
|
||||
);
|
||||
_cached = layout;
|
||||
return layout;
|
||||
throw StateError('Persistent support root is unavailable.');
|
||||
}
|
||||
final appDataRootPath =
|
||||
await _resolvePath(_appDataRootPathResolver) ?? supportRootPath;
|
||||
@ -197,7 +186,8 @@ class StoreLayoutResolver {
|
||||
try {
|
||||
final supportDirectory = await getApplicationSupportDirectory();
|
||||
return '${supportDirectory.path}/xworkmate';
|
||||
} catch (_) {
|
||||
} catch (error) {
|
||||
debugPrint('Application support directory lookup failed: $error');
|
||||
return defaultUserSettingsRootPath();
|
||||
}
|
||||
}
|
||||
@ -213,7 +203,8 @@ class StoreLayoutResolver {
|
||||
return null;
|
||||
}
|
||||
return normalizeStoreDirectoryPath(trimmed);
|
||||
} catch (_) {
|
||||
} catch (error) {
|
||||
debugPrint('Store layout path resolver failed: $error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -302,7 +293,8 @@ Object? decodeYamlDocument(String raw) {
|
||||
}
|
||||
try {
|
||||
return _yamlToObject(loadYaml(trimmed));
|
||||
} catch (_) {
|
||||
} catch (error) {
|
||||
debugPrint('YAML decode failed: $error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,36 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
/// DEPRECATED: Local Go core execution is disabled.
|
||||
enum GoCoreLaunchSource { buildArtifact }
|
||||
|
||||
/// DEPRECATED: Local Go core execution is disabled.
|
||||
class GoCoreLaunch {
|
||||
const GoCoreLaunch({
|
||||
required this.executable,
|
||||
required this.source,
|
||||
this.arguments = const <String>[],
|
||||
this.workingDirectory,
|
||||
});
|
||||
|
||||
final String executable;
|
||||
final GoCoreLaunchSource source;
|
||||
final List<String> arguments;
|
||||
final String? workingDirectory;
|
||||
}
|
||||
|
||||
typedef GoCoreBinaryExistsResolver = Future<bool> Function(String command);
|
||||
|
||||
/// DEPRECATED: Local Go core locator is disabled.
|
||||
class GoCoreLocator {
|
||||
GoCoreLocator({
|
||||
GoCoreBinaryExistsResolver? binaryExistsResolver,
|
||||
String? workspaceRoot,
|
||||
String Function()? resolvedExecutableResolver,
|
||||
});
|
||||
|
||||
/// Always returns null as local execution is disabled.
|
||||
Future<GoCoreLaunch?> locate() async => null;
|
||||
|
||||
/// Always returns false as local execution is disabled.
|
||||
Future<bool> isAvailable() async => false;
|
||||
}
|
||||
@ -496,9 +496,28 @@ class GoTaskServiceResult {
|
||||
? raw['resultSummary'].toString().trim()
|
||||
: raw['summary']?.toString().trim() ?? '';
|
||||
|
||||
String get status => raw['status']?.toString().trim() ?? '';
|
||||
String get status => _firstNestedGoTaskString(
|
||||
raw,
|
||||
const <List<String>>[
|
||||
<String>['status'],
|
||||
<String>['error', 'status'],
|
||||
<String>['details', 'status'],
|
||||
<String>['payload', 'status'],
|
||||
<String>['result', 'status'],
|
||||
],
|
||||
);
|
||||
|
||||
String get code => raw['code']?.toString().trim() ?? '';
|
||||
String get code => _firstNestedGoTaskString(
|
||||
raw,
|
||||
const <List<String>>[
|
||||
<String>['code'],
|
||||
<String>['error', 'code'],
|
||||
<String>['error', 'details', 'code'],
|
||||
<String>['details', 'code'],
|
||||
<String>['payload', 'code'],
|
||||
<String>['result', 'code'],
|
||||
],
|
||||
);
|
||||
|
||||
bool get isOpenClawRunningTaskHandle {
|
||||
final normalizedStatus = status.trim().toLowerCase();
|
||||
@ -793,7 +812,8 @@ GoTaskServiceResult goTaskServiceResultFromAcpResponse(
|
||||
.map((item) => item['id']?.toString().trim() ?? '')
|
||||
.where((item) => item.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
final success = _boolValue(result['success']) ?? true;
|
||||
final success =
|
||||
_boolValue(result['success']) ?? _inferGoTaskSuccess(result);
|
||||
final fallbackFailureText = () {
|
||||
if (success) {
|
||||
return '';
|
||||
@ -821,7 +841,7 @@ GoTaskServiceResult goTaskServiceResultFromAcpResponse(
|
||||
? streamedText.trim()
|
||||
: '')
|
||||
.trim();
|
||||
final directErrorMessage = result['error']?.toString().trim() ?? '';
|
||||
final directErrorMessage = _extractGoTaskDisplayText(result['error']);
|
||||
final effectiveErrorMessage = success
|
||||
? directErrorMessage
|
||||
: fallbackFailureText.isNotEmpty
|
||||
@ -843,6 +863,44 @@ GoTaskServiceResult goTaskServiceResultFromAcpResponse(
|
||||
);
|
||||
}
|
||||
|
||||
bool _inferGoTaskSuccess(Map<String, dynamic> result) {
|
||||
if (result.containsKey('error')) {
|
||||
return false;
|
||||
}
|
||||
final status = _firstNestedGoTaskString(
|
||||
result,
|
||||
const <List<String>>[
|
||||
<String>['status'],
|
||||
<String>['details', 'status'],
|
||||
<String>['payload', 'status'],
|
||||
<String>['result', 'status'],
|
||||
],
|
||||
).toLowerCase();
|
||||
if (status == 'failed' ||
|
||||
status == 'error' ||
|
||||
status == 'artifact_missing' ||
|
||||
status == 'cancelled' ||
|
||||
status == 'canceled') {
|
||||
return false;
|
||||
}
|
||||
final code = _firstNestedGoTaskString(
|
||||
result,
|
||||
const <List<String>>[
|
||||
<String>['code'],
|
||||
<String>['details', 'code'],
|
||||
<String>['payload', 'code'],
|
||||
<String>['result', 'code'],
|
||||
],
|
||||
).toUpperCase();
|
||||
if (code == 'OPENCLAW_REQUIRED_ARTIFACT_MISSING' ||
|
||||
code == 'OPENCLAW_ARTIFACT_MISSING' ||
|
||||
code == 'OPENCLAW_NO_EXPORTED_ARTIFACTS' ||
|
||||
code == 'ARTIFACT_MISSING') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
String _firstGoTaskFailureText(Map<String, dynamic> result) {
|
||||
for (final key in const <String>[
|
||||
'error',
|
||||
@ -1061,6 +1119,27 @@ List<Map<String, dynamic>> _castMapList(Object? raw) {
|
||||
return raw.map(_castMap).toList(growable: false);
|
||||
}
|
||||
|
||||
String _firstNestedGoTaskString(
|
||||
Map<String, dynamic> source,
|
||||
List<List<String>> paths,
|
||||
) {
|
||||
for (final path in paths) {
|
||||
Object? current = source;
|
||||
for (final key in path) {
|
||||
if (current is! Map) {
|
||||
current = null;
|
||||
break;
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
final value = current?.toString().trim() ?? '';
|
||||
if (value.isNotEmpty) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
List<dynamic> _castList(Object? raw) {
|
||||
if (raw is List) {
|
||||
return raw;
|
||||
|
||||
@ -212,7 +212,6 @@ class SettingsController extends ChangeNotifier {
|
||||
Future<String> resolveSecretValueInternal({
|
||||
String explicitValue = '',
|
||||
String refName = '',
|
||||
String fallbackRefName = '',
|
||||
String accountTarget = '',
|
||||
bool allowVaultLookup = true,
|
||||
bool persistExplicitValue = true,
|
||||
@ -220,7 +219,6 @@ class SettingsController extends ChangeNotifier {
|
||||
this,
|
||||
explicitValue: explicitValue,
|
||||
refName: refName,
|
||||
fallbackRefName: fallbackRefName,
|
||||
accountTarget: accountTarget,
|
||||
allowVaultLookup: allowVaultLookup,
|
||||
persistExplicitValue: persistExplicitValue,
|
||||
|
||||
@ -14,8 +14,6 @@ extension SettingsControllerAccountExtension on SettingsController {
|
||||
pendingAccountMfaTicketInternal.trim().isNotEmpty && !accountSignedIn;
|
||||
bool get hasEffectiveAiGatewayApiKey =>
|
||||
secureRefsInternal.containsKey(aiGatewayApiKeyRefInternal()) ||
|
||||
(aiGatewayApiKeyRefInternal() == 'ai_gateway_api_key' &&
|
||||
secureRefsInternal.containsKey('ai_gateway_api_key')) ||
|
||||
secureRefsInternal.containsKey(
|
||||
kAccountManagedSecretTargetAIGatewayAccessToken,
|
||||
);
|
||||
@ -43,7 +41,6 @@ extension SettingsControllerAccountExtension on SettingsController {
|
||||
Future<String> loadEffectiveAiGatewayApiKey() async {
|
||||
return resolveSecretValueInternal(
|
||||
refName: snapshotInternal.aiGateway.apiKeyRef,
|
||||
fallbackRefName: 'ai_gateway_api_key',
|
||||
accountTarget: kAccountManagedSecretTargetAIGatewayAccessToken,
|
||||
);
|
||||
}
|
||||
@ -64,7 +61,6 @@ extension SettingsControllerAccountExtension on SettingsController {
|
||||
|
||||
return resolveSecretValueInternal(
|
||||
refName: gatewayTokenRefForProfileInternal(resolvedProfileIndex),
|
||||
fallbackRefName: SecretStore.gatewayTokenRefKey(resolvedProfileIndex),
|
||||
accountTarget: resolvedProfileIndex == kGatewayRemoteProfileIndex
|
||||
? kAccountManagedSecretTargetBridgeAuthToken
|
||||
: '',
|
||||
@ -77,7 +73,6 @@ extension SettingsControllerAccountExtension on SettingsController {
|
||||
|
||||
return resolveSecretValueInternal(
|
||||
refName: gatewayPasswordRefForProfileInternal(resolvedProfileIndex),
|
||||
fallbackRefName: SecretStore.gatewayPasswordRefKey(resolvedProfileIndex),
|
||||
allowVaultLookup: true,
|
||||
);
|
||||
}
|
||||
|
||||
@ -149,14 +149,12 @@ Future<AiGatewayProfile> syncAiGatewayCatalogSettingsInternal(
|
||||
? await controller.resolveSecretValueInternal(
|
||||
explicitValue: apiKeyOverride,
|
||||
refName: profile.apiKeyRef,
|
||||
fallbackRefName: 'ai_gateway_api_key',
|
||||
accountTarget: kAccountManagedSecretTargetAIGatewayAccessToken,
|
||||
allowVaultLookup: false,
|
||||
persistExplicitValue: false,
|
||||
)
|
||||
: await controller.resolveSecretValueInternal(
|
||||
refName: profile.apiKeyRef,
|
||||
fallbackRefName: 'ai_gateway_api_key',
|
||||
accountTarget: kAccountManagedSecretTargetAIGatewayAccessToken,
|
||||
);
|
||||
if (apiKey.isEmpty && !_allowsAnonymousAiGatewayInternal(normalizedBaseUrl)) {
|
||||
@ -254,14 +252,12 @@ Future<AiGatewayConnectionCheck> testAiGatewayConnectionSettingsInternal(
|
||||
? await controller.resolveSecretValueInternal(
|
||||
explicitValue: apiKeyOverride,
|
||||
refName: profile.apiKeyRef,
|
||||
fallbackRefName: 'ai_gateway_api_key',
|
||||
accountTarget: kAccountManagedSecretTargetAIGatewayAccessToken,
|
||||
allowVaultLookup: false,
|
||||
persistExplicitValue: false,
|
||||
)
|
||||
: await controller.resolveSecretValueInternal(
|
||||
refName: profile.apiKeyRef,
|
||||
fallbackRefName: 'ai_gateway_api_key',
|
||||
accountTarget: kAccountManagedSecretTargetAIGatewayAccessToken,
|
||||
);
|
||||
final endpoint = controller
|
||||
@ -320,14 +316,12 @@ Future<List<GatewayModelSummary>> loadAiGatewayModelsSettingsInternal(
|
||||
? await controller.resolveSecretValueInternal(
|
||||
explicitValue: apiKeyOverride,
|
||||
refName: activeProfile.apiKeyRef,
|
||||
fallbackRefName: 'ai_gateway_api_key',
|
||||
accountTarget: kAccountManagedSecretTargetAIGatewayAccessToken,
|
||||
allowVaultLookup: false,
|
||||
persistExplicitValue: false,
|
||||
)
|
||||
: await controller.resolveSecretValueInternal(
|
||||
refName: activeProfile.apiKeyRef,
|
||||
fallbackRefName: 'ai_gateway_api_key',
|
||||
accountTarget: kAccountManagedSecretTargetAIGatewayAccessToken,
|
||||
);
|
||||
if (apiKey.isEmpty && !_allowsAnonymousAiGatewayInternal(normalizedBaseUrl)) {
|
||||
|
||||
@ -424,15 +424,12 @@ Future<String> resolveSecretValueSettingsInternal(
|
||||
SettingsController controller, {
|
||||
String explicitValue = '',
|
||||
String refName = '',
|
||||
String fallbackRefName = '',
|
||||
String accountTarget = '',
|
||||
bool allowVaultLookup = true,
|
||||
bool persistExplicitValue = true,
|
||||
}) async {
|
||||
final trimmedExplicit = explicitValue.trim();
|
||||
final normalizedRef = refName.trim().isNotEmpty
|
||||
? refName.trim()
|
||||
: fallbackRefName.trim();
|
||||
final normalizedRef = refName.trim();
|
||||
if (trimmedExplicit.isNotEmpty) {
|
||||
if (persistExplicitValue && normalizedRef.isNotEmpty) {
|
||||
await controller.storeInternal.saveSecretValueByRef(
|
||||
@ -463,8 +460,9 @@ Future<String> resolveSecretValueSettingsInternal(
|
||||
);
|
||||
return vaultValue;
|
||||
}
|
||||
} catch (_) {
|
||||
// Keep account-managed fallback available even when Vault lookup fails.
|
||||
} catch (error) {
|
||||
debugPrint('Vault secret lookup failed for $normalizedRef: $error');
|
||||
// Keep account-managed secret resolution available even when Vault lookup fails.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'file_store_support.dart';
|
||||
import 'runtime_models.dart';
|
||||
|
||||
@ -127,7 +129,8 @@ class SecretStore {
|
||||
_secureStorage = FileSecureStorageClient(
|
||||
() async => _layout?.secretDirectory,
|
||||
);
|
||||
} catch (_) {
|
||||
} catch (error) {
|
||||
debugPrint('Secret store initialization failed: $error');
|
||||
_layout = null;
|
||||
_secureStorage = null;
|
||||
}
|
||||
@ -231,7 +234,8 @@ class SecretStore {
|
||||
return AccountSessionSummary.fromJson(
|
||||
(jsonDecode(raw!) as Map).cast<String, dynamic>(),
|
||||
);
|
||||
} catch (_) {
|
||||
} catch (error) {
|
||||
debugPrint('Account session summary decode failed: $error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -251,7 +255,8 @@ class SecretStore {
|
||||
return AccountSyncState.fromJson(
|
||||
(jsonDecode(raw!) as Map).cast<String, dynamic>(),
|
||||
);
|
||||
} catch (_) {
|
||||
} catch (error) {
|
||||
debugPrint('Account sync state decode failed: $error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -532,7 +537,8 @@ class SecretStore {
|
||||
.map((item) => item.toString().trim())
|
||||
.where((item) => item.isNotEmpty)
|
||||
.toSet();
|
||||
} catch (_) {
|
||||
} catch (error) {
|
||||
debugPrint('Custom secret ref registry decode failed: $error');
|
||||
return <String>{};
|
||||
}
|
||||
}
|
||||
@ -561,7 +567,8 @@ class SecretStore {
|
||||
_memorySecure[key] = value;
|
||||
return value;
|
||||
}
|
||||
} catch (_) {
|
||||
} catch (error) {
|
||||
debugPrint('Secure read failed for $key: $error');
|
||||
// Fall back to memory only when the secret path is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,105 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
import 'package:xworkmate/features/desktop/desktop_client.dart';
|
||||
|
||||
class FakeMediaStream extends MediaStream {
|
||||
FakeMediaStream(String id) : super(id, 'test');
|
||||
|
||||
final List<MediaStreamTrack> tracks = [];
|
||||
final List<bool> addToNativeValues = [];
|
||||
|
||||
@override
|
||||
bool? get active => true;
|
||||
|
||||
@override
|
||||
Future<void> addTrack(
|
||||
MediaStreamTrack track, {
|
||||
bool addToNative = true,
|
||||
}) async {
|
||||
tracks.add(track);
|
||||
addToNativeValues.add(addToNative);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> getMediaTracks() async {}
|
||||
|
||||
@override
|
||||
List<MediaStreamTrack> getAudioTracks() =>
|
||||
tracks.where((track) => track.kind == 'audio').toList();
|
||||
|
||||
@override
|
||||
MediaStreamTrack? getTrackById(String trackId) {
|
||||
for (final track in tracks) {
|
||||
if (track.id == trackId) {
|
||||
return track;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
List<MediaStreamTrack> getTracks() => List.unmodifiable(tracks);
|
||||
|
||||
@override
|
||||
List<MediaStreamTrack> getVideoTracks() =>
|
||||
tracks.where((track) => track.kind == 'video').toList();
|
||||
|
||||
@override
|
||||
Future<void> removeTrack(
|
||||
MediaStreamTrack track, {
|
||||
bool removeFromNative = true,
|
||||
}) async {
|
||||
tracks.remove(track);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeMediaStreamTrack extends MediaStreamTrack {
|
||||
FakeMediaStreamTrack({required this.trackId, required this.trackKind});
|
||||
|
||||
final String trackId;
|
||||
final String trackKind;
|
||||
bool _enabled = true;
|
||||
|
||||
@override
|
||||
Future<ByteBuffer> captureFrame() {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
|
||||
@override
|
||||
bool get enabled => _enabled;
|
||||
|
||||
@override
|
||||
set enabled(bool b) {
|
||||
_enabled = b;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> hasTorch() async => false;
|
||||
|
||||
@override
|
||||
String? get id => trackId;
|
||||
|
||||
@override
|
||||
String? get kind => trackKind;
|
||||
|
||||
@override
|
||||
String? get label => trackKind;
|
||||
|
||||
@override
|
||||
bool? get muted => false;
|
||||
|
||||
@override
|
||||
Future<void> setTorch(bool torch) async {}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {}
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('DesktopClient protocol helpers', () {
|
||||
test('normalizes WebRTC connection states for view gating', () {
|
||||
@ -42,5 +140,62 @@ void main() {
|
||||
expect(params['width'], 1280);
|
||||
expect(params['height'], 720);
|
||||
});
|
||||
|
||||
test('uses bridge-provided remote stream when present', () async {
|
||||
var fallbackCreated = false;
|
||||
final providedStream = FakeMediaStream('provided-stream');
|
||||
final track = FakeMediaStreamTrack(
|
||||
trackId: 'video-track-1',
|
||||
trackKind: 'video',
|
||||
);
|
||||
|
||||
final stream = await desktopRemoteVideoStreamForTrack(
|
||||
RTCTrackEvent(streams: [providedStream], track: track),
|
||||
createFallbackStream: (label) async {
|
||||
fallbackCreated = true;
|
||||
return FakeMediaStream(label);
|
||||
},
|
||||
);
|
||||
|
||||
expect(stream, same(providedStream));
|
||||
expect(fallbackCreated, isFalse);
|
||||
expect(providedStream.tracks, isEmpty);
|
||||
});
|
||||
|
||||
test('synthesizes stream for streamless remote video track', () async {
|
||||
final track = FakeMediaStreamTrack(
|
||||
trackId: 'video-track-1',
|
||||
trackKind: 'video',
|
||||
);
|
||||
final fallbackStream = FakeMediaStream('fallback-stream');
|
||||
|
||||
final stream = await desktopRemoteVideoStreamForTrack(
|
||||
RTCTrackEvent(streams: const [], track: track),
|
||||
createFallbackStream: (label) async => fallbackStream,
|
||||
);
|
||||
|
||||
expect(stream, same(fallbackStream));
|
||||
expect(fallbackStream.tracks, [same(track)]);
|
||||
expect(fallbackStream.addToNativeValues, [isFalse]);
|
||||
});
|
||||
|
||||
test('ignores streamless non-video tracks', () async {
|
||||
var fallbackCreated = false;
|
||||
final track = FakeMediaStreamTrack(
|
||||
trackId: 'audio-track-1',
|
||||
trackKind: 'audio',
|
||||
);
|
||||
|
||||
final stream = await desktopRemoteVideoStreamForTrack(
|
||||
RTCTrackEvent(streams: const [], track: track),
|
||||
createFallbackStream: (label) async {
|
||||
fallbackCreated = true;
|
||||
return FakeMediaStream(label);
|
||||
},
|
||||
);
|
||||
|
||||
expect(stream, isNull);
|
||||
expect(fallbackCreated, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -1307,6 +1307,13 @@ void main() {
|
||||
final request = fakeGoTaskService.requests.single;
|
||||
expect(request.metadata, isNot(contains('taskLoadClass')));
|
||||
expect(request.metadata, isNot(contains('expectedArtifactExtensions')));
|
||||
expect(request.metadata, contains('xworkmateTaskArtifactContract'));
|
||||
final artifactContract =
|
||||
(request.metadata['xworkmateTaskArtifactContract'] as Map)
|
||||
.cast<String, dynamic>();
|
||||
expect(artifactContract['finalDeliverableDetection'], 'remote-runtime');
|
||||
expect(artifactContract['requiresExportBeforeFinalResponse'], isTrue);
|
||||
expect(artifactContract, isNot(contains('expectedArtifactExtensions')));
|
||||
expect(request.prompt, isNot(contains('Task load classification:')));
|
||||
expect(
|
||||
request.prompt,
|
||||
@ -1345,7 +1352,22 @@ void main() {
|
||||
final request = fakeGoTaskService.requests.single;
|
||||
expect(request.metadata, isNot(contains('taskLoadClass')));
|
||||
expect(request.metadata, isNot(contains('expectedArtifactExtensions')));
|
||||
expect(request.metadata, contains('xworkmateTaskArtifactContract'));
|
||||
final artifactContract =
|
||||
(request.metadata['xworkmateTaskArtifactContract'] as Map)
|
||||
.cast<String, dynamic>();
|
||||
expect(artifactContract['scopeKind'], 'task');
|
||||
expect(artifactContract['rejectTextOnlyFileClaims'], isTrue);
|
||||
expect(
|
||||
artifactContract['currentTaskWorkspace'],
|
||||
request.workingDirectory,
|
||||
);
|
||||
expect(request.prompt, isNot(contains('Required final artifact')));
|
||||
expect(request.prompt, contains('XWorkmate task artifact contract:'));
|
||||
expect(
|
||||
request.prompt,
|
||||
contains('export the final deliverables through the current XWorkmate task artifact scope'),
|
||||
);
|
||||
expect(request.prompt, contains('最后 输出 PDF文件'));
|
||||
},
|
||||
);
|
||||
@ -1931,6 +1953,68 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'sendChatMessage treats nested OpenClaw artifact errors as terminal failures',
|
||||
() async {
|
||||
final fakeGoTaskService = _RecordingGoTaskServiceClient()
|
||||
..outcomes.add(
|
||||
goTaskServiceResultFromAcpResponse(
|
||||
const <String, dynamic>{
|
||||
'jsonrpc': '2.0',
|
||||
'id': 'nested-openclaw-artifact-error',
|
||||
'result': <String, dynamic>{
|
||||
'status': 'failed',
|
||||
'error': <String, dynamic>{
|
||||
'code': 'OPENCLAW_REQUIRED_ARTIFACT_MISSING',
|
||||
'message':
|
||||
'openclaw returned partial artifacts without required final deliverables',
|
||||
},
|
||||
},
|
||||
},
|
||||
route: GoTaskServiceRoute.externalAcpSingle,
|
||||
),
|
||||
)
|
||||
..outcomes.add(
|
||||
const GoTaskServiceResult(
|
||||
success: true,
|
||||
message: 'new OpenClaw session delivered final artifacts',
|
||||
turnId: 'turn-2',
|
||||
raw: <String, dynamic>{},
|
||||
errorMessage: '',
|
||||
resolvedModel: '',
|
||||
route: GoTaskServiceRoute.externalAcpSingle,
|
||||
),
|
||||
);
|
||||
final controller = _connectedController(fakeGoTaskService);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await controller.sessionsController.switchSession(
|
||||
'unit-fixture-task-a',
|
||||
);
|
||||
|
||||
await controller.sendChatMessage('first turn');
|
||||
|
||||
expect(fakeGoTaskService.requests, hasLength(1));
|
||||
final failedThread = controller.taskThreadForSessionInternal(
|
||||
'unit-fixture-task-a',
|
||||
);
|
||||
expect(
|
||||
failedThread?.lifecycleState.lastResultCode,
|
||||
'OPENCLAW_REQUIRED_ARTIFACT_MISSING',
|
||||
);
|
||||
expect(failedThread?.lastArtifactSyncStatus, 'failed');
|
||||
|
||||
await controller.sendChatMessage('retry final artifact');
|
||||
|
||||
expect(fakeGoTaskService.requests, hasLength(2));
|
||||
expect(fakeGoTaskService.requests.last.resumeSession, isFalse);
|
||||
await _waitForLastChatMessageText(
|
||||
controller,
|
||||
'new OpenClaw session delivered final artifacts',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'sendChatMessage hides OpenClaw artifact guard text from failed results and streaming',
|
||||
() async {
|
||||
@ -4355,6 +4439,21 @@ UiFeatureManifest _defaultDesktopManifest() {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _resilientDelete(Directory dir) async {
|
||||
if (!await dir.exists()) {
|
||||
return;
|
||||
}
|
||||
for (var attempt = 0; attempt < 8; attempt++) {
|
||||
try {
|
||||
await dir.delete(recursive: true);
|
||||
return;
|
||||
} catch (_) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
}
|
||||
}
|
||||
await dir.delete(recursive: true);
|
||||
}
|
||||
|
||||
AppController _sandboxController({
|
||||
SecureConfigStore? store,
|
||||
RuntimeCoordinator? runtimeCoordinator,
|
||||
@ -4371,10 +4470,7 @@ AppController _sandboxController({
|
||||
final actualHome = homeDir ?? Directory.systemTemp.createTempSync('xworkmate-sandbox-home-').path;
|
||||
if (homeDir == null) {
|
||||
addTearDown(() async {
|
||||
final dir = Directory(actualHome);
|
||||
if (await dir.exists()) {
|
||||
await dir.delete(recursive: true);
|
||||
}
|
||||
await _resilientDelete(Directory(actualHome));
|
||||
});
|
||||
}
|
||||
return AppController(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user