From bc459d10c9fd1abb4069ba9b52e7c873a29322bb Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 8 Jun 2026 13:20:38 +0800 Subject: [PATCH 01/15] Add AI workspace management provisioning flow --- lib/features/desktop/desktop_view.dart | 11 + .../workspace_management/playbook_runner.dart | 274 +++++++++++++++ .../workspace_management/server_detector.dart | 100 ++++++ .../workspace_management/ssh_executor.dart | 108 ++++++ .../workspace_management_form.dart | 299 +++++++++++++++++ .../workspace_management_i18n.dart | 29 ++ .../workspace_management_panel.dart | 244 ++++++++++++++ .../workspace_management_result.dart | 147 ++++++++ .../workspace_management_steps.dart | 116 +++++++ .../workspace_provision_controller.dart | 313 ++++++++++++++++++ .../workspace_provision_models.dart | 231 +++++++++++++ pubspec.lock | 40 +++ pubspec.yaml | 1 + .../settings_remote_desktop_panel_test.dart | 2 + .../workspace_management_unit_test.dart | 220 ++++++++++++ .../workspace_management_widget_test.dart | 180 ++++++++++ 16 files changed, 2315 insertions(+) create mode 100644 lib/features/workspace_management/playbook_runner.dart create mode 100644 lib/features/workspace_management/server_detector.dart create mode 100644 lib/features/workspace_management/ssh_executor.dart create mode 100644 lib/features/workspace_management/workspace_management_form.dart create mode 100644 lib/features/workspace_management/workspace_management_i18n.dart create mode 100644 lib/features/workspace_management/workspace_management_panel.dart create mode 100644 lib/features/workspace_management/workspace_management_result.dart create mode 100644 lib/features/workspace_management/workspace_management_steps.dart create mode 100644 lib/features/workspace_management/workspace_provision_controller.dart create mode 100644 lib/features/workspace_management/workspace_provision_models.dart create mode 100644 test/features/workspace_management/workspace_management_unit_test.dart create mode 100644 test/features/workspace_management/workspace_management_widget_test.dart diff --git a/lib/features/desktop/desktop_view.dart b/lib/features/desktop/desktop_view.dart index 8bdd5272..9b39d3db 100644 --- a/lib/features/desktop/desktop_view.dart +++ b/lib/features/desktop/desktop_view.dart @@ -8,6 +8,8 @@ import '../../app/app_controller.dart'; import '../../runtime/gateway_acp_client.dart'; import '../../widgets/surface_card.dart'; import '../../i18n/app_language.dart'; +import '../workspace_management/workspace_management_panel.dart'; +import '../workspace_management/workspace_management_i18n.dart'; class DesktopView extends StatefulWidget { const DesktopView({ @@ -369,6 +371,15 @@ class _DesktopViewState extends State { ), label: const Text('高级选项'), ), + OutlinedButton.icon( + key: const Key('desktop-workspace-management-button'), + onPressed: () => WorkspaceManagementPanel.show( + context, + widget.controller, + ), + icon: const Icon(Icons.dns_outlined), + label: Text(WorkspaceManagementText.button), + ), // Maximize Toggle if (widget.onToggleMaximize != null) IconButton( diff --git a/lib/features/workspace_management/playbook_runner.dart b/lib/features/workspace_management/playbook_runner.dart new file mode 100644 index 00000000..31154558 --- /dev/null +++ b/lib/features/workspace_management/playbook_runner.dart @@ -0,0 +1,274 @@ +import 'dart:async'; + +import '../../i18n/app_language.dart'; +import 'server_detector.dart'; +import 'ssh_executor.dart'; +import 'workspace_provision_models.dart'; + +class PlaybookRunner { + const PlaybookRunner(this.executor); + + static const String playbookRepoUrl = 'https://github.com/x-evor/playbooks.git'; + static const String createPlaybook = 'setup-ai-workspace-all-in-one.yml'; + static const String upgradePlaybook = 'upgrade-ai-workspace.yml'; + + final WorkspaceSshExecutor executor; + + Future run({ + required SshConfig ssh, + required String action, + required String workspaceDomain, + required String bridgeToken, + required String installPath, + required bool installMissingPrerequisites, + required ServerInfo? serverInfo, + required void Function(String stepId, StepStatus status, String? message) + onStepUpdate, + required void Function(String logLine) onLog, + }) async { + if (action == 'upgrade') { + throw PlaybookRunException( + appText( + 'playbooks 仓库尚未提供 $upgradePlaybook。', + 'The playbooks repository does not provide $upgradePlaybook yet.', + ), + ); + } + + var info = serverInfo; + if (info == null) { + onStepUpdate('ssh_connect', StepStatus.running, null); + info = await ServerDetector(executor).detect(ssh, workspaceDomain); + onStepUpdate('ssh_connect', StepStatus.success, null); + onStepUpdate('detect_env', StepStatus.success, info.displaySummary); + } + + if (info.hasMissingPrerequisites) { + if (!installMissingPrerequisites) { + throw PlaybookRunException( + appText( + '目标服务器缺少 git 或 ansible。', + 'The target server is missing git or ansible.', + ), + ); + } + onStepUpdate('install_deps', StepStatus.running, appText('安装 git/ansible', 'Installing git/ansible')); + await _executeChecked(ssh, _preflightInstallCommand(ssh), onLog); + onStepUpdate('install_deps', StepStatus.success, appText('基础依赖已安装', 'Base dependencies installed')); + } + + onStepUpdate('install_deps', StepStatus.running, appText('拉取 playbooks', 'Fetching playbooks')); + await _executeChecked(ssh, _cloneOrPullCommand(installPath), onLog); + + final inventoryPath = '/tmp/xworkspace-inventory.ini'; + final varsPath = '/tmp/xworkspace-vars.yml'; + await _executeChecked( + ssh, + _writeInventoryAndVarsCommand( + inventoryPath: inventoryPath, + varsPath: varsPath, + workspaceDomain: workspaceDomain, + bridgeToken: bridgeToken, + ), + onLog, + ); + + await _runAnsible( + ssh: ssh, + command: _ansibleCommand( + installPath: installPath, + inventoryPath: inventoryPath, + varsPath: varsPath, + ), + onStepUpdate: onStepUpdate, + onLog: onLog, + ); + } + + Future _executeChecked( + SshConfig ssh, + String command, + void Function(String logLine) onLog, + ) async { + final result = await executor.execute(ssh, command); + for (final line in result.combinedOutput.split(RegExp(r'\r?\n'))) { + if (line.trim().isNotEmpty) { + onLog(line); + } + } + if (!result.success) { + throw PlaybookRunException(result.combinedOutput.trim()); + } + } + + Future _runAnsible({ + required SshConfig ssh, + required String command, + required void Function(String stepId, StepStatus status, String? message) + onStepUpdate, + required void Function(String logLine) onLog, + }) async { + final parser = AnsibleOutputParser(); + var failed = false; + await for (final chunk in executor.executeStreaming(ssh, command)) { + for (final raw in chunk.split(RegExp(r'\r?\n'))) { + final line = raw.trimRight(); + if (line.isEmpty) { + continue; + } + onLog(line); + final event = parser.parseLine(line); + if (event != null) { + onStepUpdate(event.stepId, event.status, event.message); + failed = failed || event.status == StepStatus.failed; + } + if (line.startsWith('REMOTE_EXIT_CODE=')) { + failed = true; + } + } + } + if (failed) { + throw PlaybookRunException(appText('Playbook 执行失败。', 'Playbook execution failed.')); + } + for (final id in [ + 'install_deps', + 'deploy_webrtc', + 'deploy_bridge', + 'config_caddy', + 'config_gateway', + 'start_services', + ]) { + onStepUpdate(id, StepStatus.success, null); + } + } + + static String _preflightInstallCommand(SshConfig ssh) { + final apt = 'DEBIAN_FRONTEND=noninteractive apt-get update && ' + 'DEBIAN_FRONTEND=noninteractive apt-get install -y git ansible'; + if (ssh.username == 'root') { + return apt; + } + final sudoPassword = ssh.sudoPassword?.trim(); + if (sudoPassword != null && sudoPassword.isNotEmpty) { + return "printf '%s\\n' ${shellQuote(sudoPassword)} | sudo -S sh -lc ${shellQuote(apt)}"; + } + return 'sudo -n sh -lc ${shellQuote(apt)}'; + } + + static String _cloneOrPullCommand(String installPath) { + final path = shellQuote(installPath.trim()); + final repo = shellQuote(playbookRepoUrl); + return 'mkdir -p $path && cd $path && ' + 'if [ -d .git ]; then git pull --ff-only origin main; ' + 'else git clone $repo .; fi'; + } + + static String _writeInventoryAndVarsCommand({ + required String inventoryPath, + required String varsPath, + required String workspaceDomain, + required String bridgeToken, + }) { + final domain = workspaceDomain.trim(); + final publicUrl = 'https://$domain'; + return ''' +cat > ${shellQuote(inventoryPath)} <<'EOF' +[all] +localhost ansible_connection=local +EOF +cat > ${shellQuote(varsPath)} <<'EOF' +workspace_domain: $domain +xworkmate_bridge_domain: $domain +xworkmate_bridge_public_base_url: $publicUrl +xworkmate_bridge_service_domain: $domain +xworkmate_bridge_service_public_base_url: $publicUrl +xworkmate_bridge_auth_token: ${bridgeToken.trim()} +EOF +'''; + } + + static String _ansibleCommand({ + required String installPath, + required String inventoryPath, + required String varsPath, + }) { + return 'cd ${shellQuote(installPath.trim())} && ' + 'ANSIBLE_FORCE_COLOR=0 ansible-playbook ' + '-i ${shellQuote(inventoryPath)} ' + '${shellQuote(createPlaybook)} ' + '-e @${shellQuote(varsPath)} 2>&1'; + } +} + +class AnsibleStepEvent { + const AnsibleStepEvent(this.stepId, this.status, this.message); + + final String stepId; + final StepStatus status; + final String? message; +} + +class AnsibleOutputParser { + String? _currentStepId; + String? _currentTask; + + AnsibleStepEvent? parseLine(String line) { + final taskMatch = RegExp(r'^TASK \[(.+?)\]').firstMatch(line); + if (taskMatch != null) { + _currentTask = taskMatch.group(1); + _currentStepId = stepIdForTask(_currentTask ?? ''); + return AnsibleStepEvent(_currentStepId!, StepStatus.running, _currentTask); + } + if (_currentStepId == null) { + return null; + } + final lower = line.toLowerCase(); + if (lower.startsWith('fatal:') || lower.contains(' failed=')) { + return AnsibleStepEvent(_currentStepId!, StepStatus.failed, line); + } + if (lower.startsWith('ok:') || lower.startsWith('changed:')) { + return AnsibleStepEvent(_currentStepId!, StepStatus.success, _currentTask); + } + if (lower.startsWith('skipping:')) { + return AnsibleStepEvent(_currentStepId!, StepStatus.skipped, _currentTask); + } + return null; + } + + static String stepIdForTask(String task) { + final text = task.toLowerCase(); + if (text.contains('bridge') || text.contains('acp_server')) { + return 'deploy_bridge'; + } + if (text.contains('caddy') || text.contains('tls') || text.contains('cert')) { + return 'config_caddy'; + } + if (text.contains('gateway') || text.contains('openclaw')) { + return 'config_gateway'; + } + if (text.contains('systemd') || + text.contains('service') || + text.contains('enable') || + text.contains('start') || + text.contains('restart')) { + return 'start_services'; + } + if (text.contains('xworkspace') || + text.contains('console') || + text.contains('desktop') || + text.contains('ttyd') || + text.contains('chrome')) { + return 'deploy_webrtc'; + } + return 'install_deps'; + } +} + +class PlaybookRunException implements Exception { + const PlaybookRunException(this.message); + + final String message; + + @override + String toString() => message.isEmpty ? 'Playbook failed' : message; +} diff --git a/lib/features/workspace_management/server_detector.dart b/lib/features/workspace_management/server_detector.dart new file mode 100644 index 00000000..3d3ac450 --- /dev/null +++ b/lib/features/workspace_management/server_detector.dart @@ -0,0 +1,100 @@ +import 'workspace_provision_models.dart'; +import 'ssh_executor.dart'; + +class ServerDetector { + const ServerDetector(this.executor); + + final WorkspaceSshExecutor executor; + + Future detect(SshConfig ssh, String workspaceDomain) async { + final result = await executor.execute( + ssh, + detectionCommand(workspaceDomain), + ); + if (!result.success) { + throw ServerDetectionException(result.combinedOutput.trim()); + } + return parseServerInfo(result.stdout); + } + + static String detectionCommand(String workspaceDomain) { + final domain = shellQuote(workspaceDomain.trim()); + return ''' +if command -v lsb_release >/dev/null 2>&1; then + echo "OS=\$(lsb_release -ds)" +else + . /etc/os-release 2>/dev/null || true + echo "OS=\${PRETTY_NAME:-unknown}" +fi +echo "ARCH=\$(uname -m)" +echo "SUDO=\$(sudo -n true 2>/dev/null && echo yes || echo no)" +echo "DOCKER=\$(docker --version 2>/dev/null || echo missing)" +echo "SYSTEMD=\$(systemctl --version 2>/dev/null | head -1 || echo missing)" +echo "CADDY=\$(caddy version 2>/dev/null || echo missing)" +echo "ANSIBLE=\$(ansible --version 2>/dev/null | head -1 || echo missing)" +echo "GIT=\$(git --version 2>/dev/null || echo missing)" +echo "DNS_OK=\$(getent hosts $domain 2>/dev/null | wc -l | tr -d ' ')" +echo "PORT_443_LISTENERS=\$(ss -ltn '( sport = :443 )' 2>/dev/null | tail -n +2 | wc -l | tr -d ' ')" +if command -v ufw >/dev/null 2>&1; then + UFW_STATUS="\$(ufw status 2>/dev/null || sudo -n ufw status 2>/dev/null || echo unavailable)" + if printf '%s' "\$UFW_STATUS" | grep -qi 'Status: inactive'; then + echo "PORT_443_OPEN=yes" + elif printf '%s' "\$UFW_STATUS" | grep -Eqi '(^|[[:space:]])(443(/tcp)?|https)[[:space:]]+ALLOW'; then + echo "PORT_443_OPEN=yes" + else + echo "PORT_443_OPEN=no" + fi + elif command -v firewall-cmd >/dev/null 2>&1; then + FIREWALL_STATE="\$(firewall-cmd --state 2>/dev/null || sudo -n firewall-cmd --state 2>/dev/null || echo not-running)" + if [ "\$FIREWALL_STATE" = "running" ]; then + if firewall-cmd --quiet --query-service=https 2>/dev/null || + sudo -n firewall-cmd --quiet --query-service=https 2>/dev/null || + firewall-cmd --quiet --query-port=443/tcp 2>/dev/null || + sudo -n firewall-cmd --quiet --query-port=443/tcp 2>/dev/null; then + echo "PORT_443_OPEN=yes" + else + echo "PORT_443_OPEN=no" + fi + else + echo "PORT_443_OPEN=yes" + fi +else + echo "PORT_443_OPEN=yes" +fi +'''; + } + + static ServerInfo parseServerInfo(String output) { + final values = {}; + for (final raw in output.split(RegExp(r'\r?\n'))) { + final index = raw.indexOf('='); + if (index <= 0) { + continue; + } + values[raw.substring(0, index).trim()] = raw.substring(index + 1).trim(); + } + return ServerInfo( + os: values['OS'] ?? '', + arch: values['ARCH'] ?? '', + sudoAvailable: (values['SUDO'] ?? '').toLowerCase() == 'yes', + dockerVersion: values['DOCKER'] ?? 'missing', + systemdVersion: values['SYSTEMD'] ?? 'missing', + caddyVersion: values['CADDY'] ?? 'missing', + ansibleVersion: values['ANSIBLE'] ?? 'missing', + gitVersion: values['GIT'] ?? 'missing', + dnsAddressCount: int.tryParse(values['DNS_OK'] ?? '') ?? 0, + port443ListenerCount: + int.tryParse(values['PORT_443_LISTENERS'] ?? '') ?? 0, + port443Open: (values['PORT_443_OPEN'] ?? '').toLowerCase() != 'no', + ); + } +} + +class ServerDetectionException implements Exception { + const ServerDetectionException(this.message); + + final String message; + + @override + String toString() => message.isEmpty ? 'Server detection failed' : message; +} diff --git a/lib/features/workspace_management/ssh_executor.dart b/lib/features/workspace_management/ssh_executor.dart new file mode 100644 index 00000000..8e5103ce --- /dev/null +++ b/lib/features/workspace_management/ssh_executor.dart @@ -0,0 +1,108 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:dartssh2/dartssh2.dart'; + +import 'workspace_provision_models.dart'; + +abstract class WorkspaceSshExecutor { + Future execute(SshConfig config, String command); + Stream executeStreaming(SshConfig config, String command); +} + +class DartSshExecutor implements WorkspaceSshExecutor { + const DartSshExecutor(); + + @override + Future execute(SshConfig config, String command) async { + final client = await _connect(config); + try { + final result = await client.runWithResult(command); + return SshResult( + exitCode: result.exitCode ?? -1, + stdout: utf8.decode(result.stdout, allowMalformed: true), + stderr: utf8.decode(result.stderr, allowMalformed: true), + ); + } finally { + client.close(); + await client.done.catchError((_) {}); + } + } + + @override + Stream executeStreaming(SshConfig config, String command) async* { + final client = await _connect(config); + SSHSession? session; + try { + session = await client.execute(command); + final controller = StreamController(); + final subscriptions = >>[ + session.stdout.listen( + (chunk) => controller.add(utf8.decode(chunk, allowMalformed: true)), + onError: controller.addError, + ), + session.stderr.listen( + (chunk) => controller.add(utf8.decode(chunk, allowMalformed: true)), + onError: controller.addError, + ), + ]; + unawaited( + session.done.then((_) async { + for (final subscription in subscriptions) { + await subscription.cancel(); + } + await controller.close(); + }), + ); + await for (final chunk in controller.stream) { + yield chunk; + } + if ((session.exitCode ?? 0) != 0) { + yield 'REMOTE_EXIT_CODE=${session.exitCode ?? -1}'; + } + } finally { + session?.close(); + client.close(); + await client.done.catchError((_) {}); + } + } + + Future _connect(SshConfig config) async { + final socket = await SSHSocket.connect( + config.host, + config.port, + ).timeout(config.connectTimeout); + final identities = await _identities(config); + final client = SSHClient( + socket, + username: config.username, + identities: identities.isEmpty ? null : identities, + onPasswordRequest: config.authMethod == AuthMethod.password + ? () => config.password + : null, + onVerifyHostKey: (hostKey, fingerprint) => true, + ); + await client.authenticated.timeout(config.connectTimeout); + return client; + } + + Future> _identities(SshConfig config) async { + if (config.authMethod != AuthMethod.sshKey) { + return const []; + } + final inline = config.privateKey?.trim(); + if (inline != null && inline.isNotEmpty) { + return SSHKeyPair.fromPem(inline); + } + final path = config.privateKeyPath?.trim(); + if (path != null && path.isNotEmpty) { + return SSHKeyPair.fromPem(await File(path).readAsString()); + } + return const []; + } +} + +String shellQuote(String value) { + return "'${value.replaceAll("'", "'\"'\"'")}'"; +} diff --git a/lib/features/workspace_management/workspace_management_form.dart b/lib/features/workspace_management/workspace_management_form.dart new file mode 100644 index 00000000..794e30dc --- /dev/null +++ b/lib/features/workspace_management/workspace_management_form.dart @@ -0,0 +1,299 @@ +import 'package:flutter/material.dart'; + +import '../../i18n/app_language.dart'; +import 'workspace_provision_controller.dart'; +import 'workspace_provision_models.dart'; + +class WorkspaceManagementForm extends StatefulWidget { + const WorkspaceManagementForm({ + super.key, + required this.controller, + required this.onDetect, + required this.onCreate, + }); + + final WorkspaceProvisionController controller; + final VoidCallback onDetect; + final VoidCallback onCreate; + + @override + State createState() => + _WorkspaceManagementFormState(); +} + +class _WorkspaceManagementFormState extends State { + late final TextEditingController _serverController; + late final TextEditingController _domainController; + late final TextEditingController _userController; + late final TextEditingController _passwordController; + late final TextEditingController _keyController; + late final TextEditingController _keyPathController; + late final TextEditingController _portController; + late final TextEditingController _sudoController; + late final TextEditingController _installPathController; + + @override + void initState() { + super.initState(); + final c = widget.controller; + _serverController = TextEditingController(text: c.serverAddress); + _domainController = TextEditingController(text: c.workspaceDomain); + _userController = TextEditingController(text: c.sshUsername); + _passwordController = TextEditingController(text: c.sshPassword ?? ''); + _keyController = TextEditingController(text: c.sshKeyContent ?? ''); + _keyPathController = TextEditingController(text: c.sshKeyPath ?? ''); + _portController = TextEditingController(text: c.sshPort.toString()); + _sudoController = TextEditingController(text: c.sudoPassword ?? ''); + _installPathController = TextEditingController(text: c.installPath); + } + + @override + void dispose() { + _serverController.dispose(); + _domainController.dispose(); + _userController.dispose(); + _passwordController.dispose(); + _keyController.dispose(); + _keyPathController.dispose(); + _portController.dispose(); + _sudoController.dispose(); + _installPathController.dispose(); + super.dispose(); + } + + void _sync() { + widget.controller.updateForm( + serverAddress: _serverController.text.trim(), + workspaceDomain: _domainController.text.trim(), + sshUsername: _userController.text.trim(), + sshPassword: _passwordController.text, + sshKeyContent: _keyController.text, + sshKeyPath: _keyPathController.text.trim(), + sshPort: int.tryParse(_portController.text.trim()) ?? 22, + sudoPassword: _sudoController.text, + installPath: _installPathController.text.trim().isEmpty + ? '/opt/xworkspace/playbooks' + : _installPathController.text.trim(), + ); + } + + @override + Widget build(BuildContext context) { + final controller = widget.controller; + final disabled = controller.isBusy; + + return AnimatedBuilder( + animation: controller, + builder: (context, _) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + LayoutBuilder( + builder: (context, constraints) { + final columns = constraints.maxWidth > 760 ? 2 : 1; + final itemWidth = + (constraints.maxWidth - (columns - 1) * 12) / columns; + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _field( + width: itemWidth, + controller: _serverController, + enabled: !disabled, + label: appText('服务器地址 *', 'Server address *'), + icon: Icons.dns_outlined, + ), + _field( + width: itemWidth, + controller: _domainController, + enabled: !disabled, + label: appText('Workspace 域名 *', 'Workspace domain *'), + icon: Icons.public_outlined, + ), + _field( + width: itemWidth, + controller: _userController, + enabled: !disabled, + label: appText('SSH 用户名 *', 'SSH username *'), + icon: Icons.person_outline, + ), + SizedBox( + width: itemWidth, + child: SegmentedButton( + segments: [ + ButtonSegment( + value: AuthMethod.sshKey, + icon: const Icon(Icons.key_outlined), + label: Text(appText('SSH Key', 'SSH Key')), + ), + ButtonSegment( + value: AuthMethod.password, + icon: const Icon(Icons.password_outlined), + label: Text(appText('密码', 'Password')), + ), + ], + selected: {controller.authMethod}, + onSelectionChanged: disabled + ? null + : (value) => controller.updateForm( + authMethod: value.single, + ), + ), + ), + if (controller.authMethod == AuthMethod.password) + _field( + width: itemWidth, + controller: _passwordController, + enabled: !disabled, + label: appText('SSH 密码 *', 'SSH password *'), + icon: Icons.lock_outline, + obscureText: true, + ) + else ...[ + _field( + width: itemWidth, + controller: _keyPathController, + enabled: !disabled, + label: appText('SSH Key 文件路径', 'SSH key file path'), + icon: Icons.folder_outlined, + ), + _field( + width: constraints.maxWidth, + controller: _keyController, + enabled: !disabled, + label: appText('SSH Key 内容', 'SSH key content'), + icon: Icons.article_outlined, + minLines: 3, + maxLines: 5, + ), + ], + ], + ); + }, + ), + const SizedBox(height: 10), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: disabled + ? null + : () => controller.updateForm( + showAdvanced: !controller.showAdvanced, + ), + icon: Icon( + controller.showAdvanced + ? Icons.expand_less + : Icons.expand_more, + ), + label: Text(appText('高级选项', 'Advanced options')), + ), + ), + if (controller.showAdvanced) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _field( + width: 140, + controller: _portController, + enabled: !disabled, + label: appText('SSH 端口', 'SSH port'), + icon: Icons.numbers_outlined, + keyboardType: TextInputType.number, + ), + _field( + width: 220, + controller: _sudoController, + enabled: !disabled, + label: appText('sudo 密码', 'sudo password'), + icon: Icons.admin_panel_settings_outlined, + obscureText: true, + ), + _field( + width: 320, + controller: _installPathController, + enabled: !disabled, + label: appText('安装路径', 'Install path'), + icon: Icons.storage_outlined, + ), + ], + ), + ], + const SizedBox(height: 16), + Wrap( + spacing: 10, + runSpacing: 10, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + FilledButton.tonalIcon( + key: const Key('workspace-management-detect-button'), + onPressed: disabled + ? null + : () { + _sync(); + widget.onDetect(); + }, + icon: const Icon(Icons.health_and_safety_outlined), + label: Text(appText('检测服务器', 'Detect server')), + ), + FilledButton.icon( + key: const Key('workspace-management-create-button'), + onPressed: disabled + ? null + : () { + _sync(); + widget.onCreate(); + }, + icon: const Icon(Icons.rocket_launch_outlined), + label: Text(appText('创建工作空间', 'Create workspace')), + ), + Tooltip( + message: appText( + '等待 playbooks 仓库提供 upgrade-ai-workspace.yml 后启用', + 'Enabled after playbooks provides upgrade-ai-workspace.yml', + ), + child: FilledButton.tonalIcon( + key: const Key('workspace-management-upgrade-button'), + onPressed: null, + icon: const Icon(Icons.system_update_alt_outlined), + label: Text(appText('升级工作空间', 'Upgrade workspace')), + ), + ), + ], + ), + ], + ); + }, + ); + } + + Widget _field({ + required double width, + required TextEditingController controller, + required bool enabled, + required String label, + required IconData icon, + bool obscureText = false, + int minLines = 1, + int maxLines = 1, + TextInputType? keyboardType, + }) { + return SizedBox( + width: width, + child: TextField( + controller: controller, + enabled: enabled, + obscureText: obscureText, + minLines: minLines, + maxLines: obscureText ? 1 : maxLines, + keyboardType: keyboardType, + decoration: InputDecoration( + labelText: label, + prefixIcon: Icon(icon, size: 18), + ), + ), + ); + } +} diff --git a/lib/features/workspace_management/workspace_management_i18n.dart b/lib/features/workspace_management/workspace_management_i18n.dart new file mode 100644 index 00000000..0de59f88 --- /dev/null +++ b/lib/features/workspace_management/workspace_management_i18n.dart @@ -0,0 +1,29 @@ +import '../../i18n/app_language.dart'; + +class WorkspaceManagementText { + const WorkspaceManagementText._(); + + static String get button => + appText('工作空间管理', 'Workspace management'); + static String get title => + appText('创建 / 升级 AI 工作空间', 'Create / Upgrade AI Workspace'); + static String get detect => appText('检测服务器', 'Detect server'); + static String get create => appText('创建工作空间', 'Create workspace'); + static String get upgrade => appText('升级工作空间', 'Upgrade workspace'); + static String get upgradeUnavailable => appText( + '等待 playbooks 仓库提供 upgrade-ai-workspace.yml 后启用', + 'Enabled after playbooks provides upgrade-ai-workspace.yml', + ); + static String get logs => appText('查看日志', 'View logs'); + static String get copyLogs => appText('复制日志', 'Copy logs'); + static String get ready => + appText('工作空间已就绪', 'Workspace is ready'); + static String get failed => appText('执行失败', 'Provisioning failed'); + static String get connectToWorkspace => + appText('连接到该工作空间', 'Connect to this workspace'); + static String get copyAddress => appText('复制地址', 'Copy address'); + static String get requiredFields => appText( + '请填写服务器地址、Workspace 域名和认证信息。', + 'Enter server address, workspace domain, and authentication.', + ); +} diff --git a/lib/features/workspace_management/workspace_management_panel.dart b/lib/features/workspace_management/workspace_management_panel.dart new file mode 100644 index 00000000..5033da84 --- /dev/null +++ b/lib/features/workspace_management/workspace_management_panel.dart @@ -0,0 +1,244 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../app/app_controller.dart'; +import '../../i18n/app_language.dart'; +import '../../runtime/runtime_models.dart'; +import '../../widgets/surface_card.dart'; +import 'workspace_management_form.dart'; +import 'workspace_management_i18n.dart'; +import 'workspace_management_result.dart'; +import 'workspace_management_steps.dart'; +import 'workspace_provision_controller.dart'; + +class WorkspaceManagementPanel extends StatefulWidget { + const WorkspaceManagementPanel({ + super.key, + required this.appController, + WorkspaceProvisionController? provisionController, + }) : _provisionController = provisionController; + + final AppController appController; + final WorkspaceProvisionController? _provisionController; + + static Future show(BuildContext context, AppController controller) { + return showDialog( + context: context, + builder: (_) => WorkspaceManagementPanel(appController: controller), + ); + } + + @override + State createState() => + _WorkspaceManagementPanelState(); +} + +class _WorkspaceManagementPanelState extends State { + late final WorkspaceProvisionController _controller; + late final bool _ownsController; + + @override + void initState() { + super.initState(); + _ownsController = widget._provisionController == null; + _controller = + widget._provisionController ?? + WorkspaceProvisionController( + initialWorkspaceDomain: _initialWorkspaceDomain(), + ); + } + + @override + void dispose() { + if (_ownsController) { + _controller.dispose(); + } + super.dispose(); + } + + String _initialWorkspaceDomain() { + final connection = widget.appController.connection; + if (connection.status == RuntimeConnectionStatus.connected) { + final remote = connection.remoteAddress?.trim() ?? ''; + final parsed = Uri.tryParse(remote.contains('://') ? remote : 'https://$remote'); + if (parsed != null && parsed.host.trim().isNotEmpty) { + return parsed.host.trim(); + } + } + return widget.appController.settings.primaryGatewayProfile.host.trim(); + } + + Future _confirmCreate() async { + if (!_controller.canSubmit) { + await _controller.createWorkspace(); + return; + } + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(appText('确认创建工作空间', 'Confirm workspace creation')), + content: Text( + appText( + '即将在 ${_controller.serverAddress} 上创建 AI 工作空间。\n\n' + '域名: ${_controller.workspaceDomain}\n' + 'SSH 用户: ${_controller.sshUsername}\n\n' + '该操作会安装系统依赖、配置服务和启动 systemd 服务,请确认这是你自己的服务器。', + 'XWorkmate will create an AI Workspace on ${_controller.serverAddress}.\n\n' + 'Domain: ${_controller.workspaceDomain}\n' + 'SSH user: ${_controller.sshUsername}\n\n' + 'This installs system dependencies, configures services, and starts systemd services. Confirm this is your own server.', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(appText('取消', 'Cancel')), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(appText('确认创建', 'Create')), + ), + ], + ), + ); + if (confirmed == true) { + unawaited(_controller.createWorkspace(installMissingPrerequisites: true)); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Dialog( + insetPadding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 980, maxHeight: 820), + child: SurfaceCard( + child: AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 14, 8), + child: Row( + children: [ + Icon( + Icons.dns_outlined, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + WorkspaceManagementText.title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + ), + IconButton( + onPressed: _controller.isBusy + ? null + : () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + tooltip: appText('关闭', 'Close'), + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + WorkspaceManagementForm( + controller: _controller, + onDetect: () => unawaited(_controller.detectServer()), + onCreate: () => unawaited(_confirmCreate()), + ), + const SizedBox(height: 20), + WorkspaceManagementSteps(steps: _controller.steps), + const SizedBox(height: 12), + _LogPanel(controller: _controller), + const SizedBox(height: 12), + WorkspaceManagementResult(controller: _controller), + ], + ), + ), + ), + ], + ); + }, + ), + ), + ), + ); + } +} + +class _LogPanel extends StatelessWidget { + const _LogPanel({required this.controller}); + + final WorkspaceProvisionController controller; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + TextButton.icon( + key: const Key('workspace-management-log-toggle'), + onPressed: () => controller.updateForm( + logsExpanded: !controller.logsExpanded, + ), + icon: Icon( + controller.logsExpanded ? Icons.expand_less : Icons.expand_more, + ), + label: Text(WorkspaceManagementText.logs), + ), + const Spacer(), + if (controller.logsExpanded) + IconButton( + onPressed: () => Clipboard.setData( + ClipboardData(text: controller.logBuffer.text), + ), + icon: const Icon(Icons.copy_outlined), + tooltip: WorkspaceManagementText.copyLogs, + ), + ], + ), + if (controller.logsExpanded) + Container( + key: const Key('workspace-management-log-content'), + constraints: const BoxConstraints(maxHeight: 220), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues( + alpha: 0.45, + ), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: SingleChildScrollView( + child: SelectableText( + controller.logBuffer.text.isEmpty + ? appText('暂无日志', 'No logs yet') + : controller.logBuffer.text, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/workspace_management/workspace_management_result.dart b/lib/features/workspace_management/workspace_management_result.dart new file mode 100644 index 00000000..4e5b9a7a --- /dev/null +++ b/lib/features/workspace_management/workspace_management_result.dart @@ -0,0 +1,147 @@ +import 'dart:io'; + +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../i18n/app_language.dart'; +import 'workspace_management_i18n.dart'; +import 'workspace_provision_controller.dart'; +import 'workspace_provision_models.dart'; + +class WorkspaceManagementResult extends StatelessWidget { + const WorkspaceManagementResult({super.key, required this.controller}); + + final WorkspaceProvisionController controller; + + @override + Widget build(BuildContext context) { + if (controller.phase == ProvisionPhase.success) { + return _success(context); + } + if (controller.phase == ProvisionPhase.failed) { + return _failure(context); + } + return const SizedBox.shrink(); + } + + Widget _success(BuildContext context) { + final theme = Theme.of(context); + final result = controller.deploymentResult; + final url = result?.url ?? ''; + final token = result?.bridgeToken ?? ''; + return Container( + key: const Key('workspace-management-result-success'), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.green.withValues(alpha: 0.35)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.check_circle, color: Colors.green), + const SizedBox(width: 8), + Text( + WorkspaceManagementText.ready, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ], + ), + if (url.isNotEmpty) ...[ + const SizedBox(height: 8), + SelectableText(url), + const SizedBox(height: 8), + Text( + appText('预生成 Bridge Token', 'Pre-generated bridge token'), + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + SelectableText(token), + const SizedBox(height: 10), + Wrap( + spacing: 8, + children: [ + OutlinedButton.icon( + onPressed: () => Clipboard.setData(ClipboardData(text: url)), + icon: const Icon(Icons.copy_outlined), + label: Text(WorkspaceManagementText.copyAddress), + ), + OutlinedButton.icon( + onPressed: () => Clipboard.setData(ClipboardData(text: token)), + icon: const Icon(Icons.key_outlined), + label: Text(appText('复制 Token', 'Copy token')), + ), + OutlinedButton.icon( + onPressed: result == null ? null : () => _downloadResult(result), + icon: const Icon(Icons.download_outlined), + label: Text(appText('下载凭据', 'Download credentials')), + ), + FilledButton.tonalIcon( + onPressed: null, + icon: const Icon(Icons.settings_remote_outlined), + label: Text(WorkspaceManagementText.connectToWorkspace), + ), + ], + ), + ], + ], + ), + ); + } + + Future _downloadResult(WorkspaceDeploymentResult result) async { + final location = await getSaveLocation( + suggestedName: 'xworkmate-bridge-credentials.txt', + ); + if (location == null) { + return; + } + await File(location.path).writeAsString(result.downloadText); + } + + Widget _failure(BuildContext context) { + final theme = Theme.of(context); + return Container( + key: const Key('workspace-management-result-failed'), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: theme.colorScheme.errorContainer.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: theme.colorScheme.error.withValues(alpha: 0.35)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.error_outline, color: theme.colorScheme.error), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + WorkspaceManagementText.failed, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + controller.errorMessage ?? + appText('请查看日志。', 'Check logs.'), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/workspace_management/workspace_management_steps.dart b/lib/features/workspace_management/workspace_management_steps.dart new file mode 100644 index 00000000..efa4a3cf --- /dev/null +++ b/lib/features/workspace_management/workspace_management_steps.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; + +import '../../i18n/app_language.dart'; +import 'workspace_provision_models.dart'; + +class WorkspaceManagementSteps extends StatelessWidget { + const WorkspaceManagementSteps({super.key, required this.steps}); + + final List steps; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + key: const Key('workspace-management-steps'), + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + appText('执行进度', 'Progress'), + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: theme.colorScheme.outlineVariant), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + for (final step in steps) + _StepRow(step: step, isLast: step == steps.last), + ], + ), + ), + ], + ); + } +} + +class _StepRow extends StatelessWidget { + const _StepRow({required this.step, required this.isLast}); + + final ProvisionStep step; + final bool isLast; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final color = _color(theme); + return Container( + key: Key('workspace-management-step-${step.id}'), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + border: isLast + ? null + : Border( + bottom: BorderSide(color: theme.colorScheme.outlineVariant), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 24, height: 24, child: _icon(color)), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + step.title, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + if ((step.message ?? '').trim().isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + step.message!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + ], + ), + ); + } + + Color _color(ThemeData theme) { + return switch (step.status) { + StepStatus.success => Colors.green, + StepStatus.failed => theme.colorScheme.error, + StepStatus.running => theme.colorScheme.primary, + StepStatus.skipped => theme.colorScheme.tertiary, + StepStatus.pending => theme.colorScheme.outline, + }; + } + + Widget _icon(Color color) { + return switch (step.status) { + StepStatus.running => CircularProgressIndicator( + strokeWidth: 2, + color: color, + ), + StepStatus.success => Icon(Icons.check_circle, color: color, size: 20), + StepStatus.failed => Icon(Icons.cancel, color: color, size: 20), + StepStatus.skipped => Icon(Icons.remove_circle, color: color, size: 20), + StepStatus.pending => Icon(Icons.radio_button_unchecked, color: color, size: 20), + }; + } +} diff --git a/lib/features/workspace_management/workspace_provision_controller.dart b/lib/features/workspace_management/workspace_provision_controller.dart new file mode 100644 index 00000000..4daced08 --- /dev/null +++ b/lib/features/workspace_management/workspace_provision_controller.dart @@ -0,0 +1,313 @@ +import 'package:flutter/foundation.dart'; + +import '../../i18n/app_language.dart'; +import 'playbook_runner.dart'; +import 'server_detector.dart'; +import 'ssh_executor.dart'; +import 'workspace_provision_models.dart'; + +class WorkspaceProvisionController extends ChangeNotifier { + WorkspaceProvisionController({ + WorkspaceSshExecutor? executor, + String initialWorkspaceDomain = '', + }) : executor = executor ?? const DartSshExecutor(), + workspaceDomain = initialWorkspaceDomain { + steps = defaultProvisionSteps(); + } + + final WorkspaceSshExecutor executor; + + String serverAddress = ''; + String workspaceDomain = ''; + String sshUsername = 'root'; + AuthMethod authMethod = AuthMethod.sshKey; + String? sshPassword; + String? sshKeyContent; + String? sshKeyPath; + int sshPort = 22; + String? sudoPassword; + String installPath = '/opt/xworkspace/playbooks'; + bool showAdvanced = false; + bool logsExpanded = false; + + ProvisionPhase phase = ProvisionPhase.idle; + late List steps; + final ProvisionLogBuffer logBuffer = ProvisionLogBuffer(); + ServerInfo? serverInfo; + WorkspaceDeploymentResult? deploymentResult; + String? errorMessage; + + bool get isBusy => + phase == ProvisionPhase.checking || phase == ProvisionPhase.running; + + bool get canSubmit { + final hasAuth = switch (authMethod) { + AuthMethod.password => (sshPassword ?? '').trim().isNotEmpty, + AuthMethod.sshKey => + (sshKeyContent ?? '').trim().isNotEmpty || + (sshKeyPath ?? '').trim().isNotEmpty, + }; + return serverAddress.trim().isNotEmpty && + workspaceDomain.trim().isNotEmpty && + sshUsername.trim().isNotEmpty && + sshPort > 0 && + hasAuth; + } + + SshConfig sshConfig() { + return SshConfig( + host: serverAddress.trim(), + port: sshPort, + username: sshUsername.trim(), + authMethod: authMethod, + password: sshPassword, + privateKey: sshKeyContent, + privateKeyPath: sshKeyPath, + sudoPassword: sudoPassword, + ); + } + + Future detectServer() async { + if (!canSubmit) { + _fail(WorkspaceProvisionValidationException()); + return; + } + _prepareRun(ProvisionPhase.checking); + _setStep('ssh_connect', StepStatus.running, null); + try { + final detected = await ServerDetector(executor).detect( + sshConfig(), + workspaceDomain.trim(), + ); + serverInfo = detected; + _setStep('ssh_connect', StepStatus.success, null); + final blockingIssue = validatePrecheckBlockingIssueFor(detected); + _setStep( + 'detect_env', + blockingIssue == null ? StepStatus.success : StepStatus.failed, + blockingIssue ?? detected.displaySummary, + ); + if (blockingIssue != null) { + _fail(WorkspaceProvisionPrecheckException(blockingIssue)); + return; + } + phase = ProvisionPhase.ready; + _appendLog(appText('服务器检测完成。', 'Server detection completed.')); + notifyListeners(); + } catch (error) { + _setStep('ssh_connect', StepStatus.failed, error.toString()); + _fail(error); + } + } + + Future createWorkspace({bool installMissingPrerequisites = false}) async { + if (!canSubmit) { + _fail(WorkspaceProvisionValidationException()); + return; + } + _prepareRun(ProvisionPhase.running, keepDetection: true); + try { + if (serverInfo == null) { + final detected = await ServerDetector(executor).detect( + sshConfig(), + workspaceDomain.trim(), + ); + serverInfo = detected; + } + final blockingIssue = validatePrecheckBlockingIssue(); + if (blockingIssue != null) { + _setStep('detect_env', StepStatus.failed, blockingIssue); + throw WorkspaceProvisionPrecheckException(blockingIssue); + } + if (serverInfo != null) { + _setStep('ssh_connect', StepStatus.success, null); + _setStep('detect_env', StepStatus.success, serverInfo!.displaySummary); + } + final bridgeToken = ensureBridgeToken(); + await PlaybookRunner(executor).run( + ssh: sshConfig(), + action: 'create', + workspaceDomain: workspaceDomain.trim(), + bridgeToken: bridgeToken, + installPath: installPath.trim(), + installMissingPrerequisites: installMissingPrerequisites, + serverInfo: serverInfo, + onStepUpdate: _setStep, + onLog: _appendLog, + ); + for (final step in steps) { + if (step.status == StepStatus.pending || step.status == StepStatus.running) { + _setStep(step.id, StepStatus.success, null); + } + } + phase = ProvisionPhase.success; + deploymentResult = WorkspaceDeploymentResult( + url: 'https://${workspaceDomain.trim()}', + bridgeToken: bridgeToken, + ); + errorMessage = null; + _appendLog(appText('工作空间创建完成。', 'Workspace creation completed.')); + notifyListeners(); + } catch (error) { + _fail(error); + } + } + + Future upgradeWorkspace() async { + _fail( + PlaybookRunException( + appText( + '升级功能等待 playbooks 仓库提供 upgrade-ai-workspace.yml 后启用。', + 'Upgrade waits for upgrade-ai-workspace.yml in the playbooks repository.', + ), + ), + ); + } + + void reset() { + phase = ProvisionPhase.idle; + steps = defaultProvisionSteps(); + logBuffer.clear(); + serverInfo = null; + deploymentResult = null; + errorMessage = null; + notifyListeners(); + } + + void updateForm({ + String? serverAddress, + String? workspaceDomain, + String? sshUsername, + AuthMethod? authMethod, + String? sshPassword, + String? sshKeyContent, + String? sshKeyPath, + int? sshPort, + String? sudoPassword, + String? installPath, + bool? showAdvanced, + bool? logsExpanded, + }) { + this.serverAddress = serverAddress ?? this.serverAddress; + this.workspaceDomain = workspaceDomain ?? this.workspaceDomain; + this.sshUsername = sshUsername ?? this.sshUsername; + this.authMethod = authMethod ?? this.authMethod; + this.sshPassword = sshPassword ?? this.sshPassword; + this.sshKeyContent = sshKeyContent ?? this.sshKeyContent; + this.sshKeyPath = sshKeyPath ?? this.sshKeyPath; + this.sshPort = sshPort ?? this.sshPort; + this.sudoPassword = sudoPassword ?? this.sudoPassword; + this.installPath = installPath ?? this.installPath; + this.showAdvanced = showAdvanced ?? this.showAdvanced; + this.logsExpanded = logsExpanded ?? this.logsExpanded; + notifyListeners(); + } + + void _prepareRun(ProvisionPhase nextPhase, {bool keepDetection = false}) { + phase = nextPhase; + errorMessage = null; + deploymentResult = null; + logBuffer.clear(); + final existingInfo = keepDetection ? serverInfo : null; + steps = defaultProvisionSteps(); + serverInfo = existingInfo; + notifyListeners(); + } + + void _setStep(String stepId, StepStatus status, String? message) { + final index = steps.indexWhere((step) => step.id == stepId); + if (index < 0) { + return; + } + final step = steps[index]; + step.status = status; + step.message = message ?? step.message; + if (status == StepStatus.running) { + step.startedAt ??= DateTime.now(); + step.finishedAt = null; + } + if (status == StepStatus.success || + status == StepStatus.failed || + status == StepStatus.skipped) { + step.finishedAt = DateTime.now(); + } + if (status == StepStatus.failed) { + step.errorDetail = message; + } + notifyListeners(); + } + + void _appendLog(String line) { + logBuffer.add(line); + notifyListeners(); + } + + void _fail(Object error) { + phase = ProvisionPhase.failed; + errorMessage = error.toString(); + _appendLog(errorMessage ?? ''); + notifyListeners(); + } + + String ensureBridgeToken() { + deploymentResult ??= WorkspaceDeploymentResult( + url: 'https://${workspaceDomain.trim()}', + bridgeToken: generateBridgeToken(), + ); + return deploymentResult!.bridgeToken; + } + + String? validatePrecheckBlockingIssue() { + return validatePrecheckBlockingIssueFor(serverInfo); + } + + String? validatePrecheckBlockingIssueFor(ServerInfo? info) { + if (info == null) { + return null; + } + if (!info.dnsResolved) { + return appText( + '部署前需要先把 ${workspaceDomain.trim()} 做好 DNS 解析。', + 'Configure DNS for ${workspaceDomain.trim()} before deploying.', + ); + } + if (!info.port443Open) { + return appText( + '目标服务器的 443 端口未开放,请先放通 HTTPS 访问。', + 'Port 443 is not open on the target server. Allow HTTPS traffic first.', + ); + } + if (!info.isPort443Available) { + return appText( + '目标服务器的 443 端口已被占用,请先释放。', + 'Port 443 is already in use on the target server.', + ); + } + return null; + } + + @override + void dispose() { + sshPassword = null; + sshKeyContent = null; + sudoPassword = null; + super.dispose(); + } +} + +class WorkspaceProvisionPrecheckException implements Exception { + const WorkspaceProvisionPrecheckException(this.message); + + final String message; + + @override + String toString() => message; +} + +class WorkspaceProvisionValidationException implements Exception { + @override + String toString() => appText( + '请填写服务器地址、Workspace 域名和认证信息。', + 'Enter server address, workspace domain, and authentication.', + ); +} diff --git a/lib/features/workspace_management/workspace_provision_models.dart b/lib/features/workspace_management/workspace_provision_models.dart new file mode 100644 index 00000000..f0af4d4c --- /dev/null +++ b/lib/features/workspace_management/workspace_provision_models.dart @@ -0,0 +1,231 @@ +import 'dart:collection'; +import 'dart:convert'; +import 'dart:math'; + +import '../../i18n/app_language.dart'; + +enum AuthMethod { password, sshKey } + +enum ProvisionPhase { idle, checking, ready, running, success, failed } + +enum StepStatus { pending, running, success, failed, skipped } + +class ProvisionStep { + ProvisionStep({ + required this.id, + required this.title, + required this.phaseGroup, + this.status = StepStatus.pending, + this.startedAt, + this.finishedAt, + this.message, + this.errorDetail, + }); + + final String id; + final String title; + final String phaseGroup; + StepStatus status; + DateTime? startedAt; + DateTime? finishedAt; + String? message; + String? errorDetail; + + ProvisionStep copy() { + return ProvisionStep( + id: id, + title: title, + phaseGroup: phaseGroup, + status: status, + startedAt: startedAt, + finishedAt: finishedAt, + message: message, + errorDetail: errorDetail, + ); + } +} + +class ServerInfo { + const ServerInfo({ + required this.os, + required this.arch, + required this.sudoAvailable, + required this.dockerVersion, + required this.systemdVersion, + required this.caddyVersion, + required this.ansibleVersion, + required this.gitVersion, + required this.dnsAddressCount, + required this.port443ListenerCount, + required this.port443Open, + }); + + final String os; + final String arch; + final bool sudoAvailable; + final String dockerVersion; + final String systemdVersion; + final String caddyVersion; + final String ansibleVersion; + final String gitVersion; + final int dnsAddressCount; + final int port443ListenerCount; + final bool port443Open; + + bool get gitMissing => _isMissing(gitVersion); + bool get ansibleMissing => _isMissing(ansibleVersion); + bool get hasMissingPrerequisites => gitMissing || ansibleMissing; + bool get dnsResolved => dnsAddressCount > 0; + bool get isPort443Available => port443ListenerCount == 0; + + String get displaySummary { + final sudo = sudoAvailable ? 'sudo=yes' : 'sudo=no'; + return [ + if (os.trim().isNotEmpty) os.trim(), + if (arch.trim().isNotEmpty) arch.trim(), + sudo, + dnsResolved ? 'dns=ok' : 'dns=missing', + port443Open ? '443=open' : '443=blocked', + isPort443Available ? '443=free' : '443=busy', + ].join(', '); + } + + static bool _isMissing(String value) => + value.trim().isEmpty || value.trim().toLowerCase() == 'missing'; +} + +class SshConfig { + const SshConfig({ + required this.host, + required this.port, + required this.username, + required this.authMethod, + this.password, + this.privateKey, + this.privateKeyPath, + this.sudoPassword, + this.connectTimeout = const Duration(seconds: 10), + }); + + final String host; + final int port; + final String username; + final AuthMethod authMethod; + final String? password; + final String? privateKey; + final String? privateKeyPath; + final String? sudoPassword; + final Duration connectTimeout; + + String get targetLabel => '$username@$host:$port'; +} + +class SshResult { + const SshResult({ + required this.exitCode, + required this.stdout, + required this.stderr, + }); + + final int exitCode; + final String stdout; + final String stderr; + + bool get success => exitCode == 0; + String get combinedOutput { + if (stderr.trim().isEmpty) { + return stdout; + } + if (stdout.trim().isEmpty) { + return stderr; + } + return '$stdout\n$stderr'; + } +} + +class ProvisionLogBuffer { + ProvisionLogBuffer({this.maxLines = 500}); + + final int maxLines; + final ListQueue _lines = ListQueue(); + + void add(String line, {DateTime? now}) { + final timestamp = (now ?? DateTime.now()).toIso8601String(); + _lines.add('[$timestamp] $line'); + while (_lines.length > maxLines) { + _lines.removeFirst(); + } + } + + void clear() => _lines.clear(); + + List get lines => List.unmodifiable(_lines); + + String get text => _lines.join('\n'); +} + +class WorkspaceDeploymentResult { + const WorkspaceDeploymentResult({ + required this.url, + required this.bridgeToken, + }); + + final String url; + final String bridgeToken; + + String get downloadText { + return 'XWorkmate Bridge URL: $url\n' + 'Bridge Auth Token: $bridgeToken\n'; + } +} + +String generateBridgeToken({int length = 32}) { + final random = Random.secure(); + final bytes = List.generate(length, (_) => random.nextInt(256)); + return base64UrlEncode(bytes).replaceAll('=', ''); +} + +List defaultProvisionSteps() { + return [ + ProvisionStep( + id: 'ssh_connect', + title: appText('SSH 连接成功', 'SSH connected'), + phaseGroup: 'detect', + ), + ProvisionStep( + id: 'detect_env', + title: appText('检测系统环境', 'Detect system environment'), + phaseGroup: 'detect', + ), + ProvisionStep( + id: 'install_deps', + title: appText('安装基础依赖', 'Install base dependencies'), + phaseGroup: 'system', + ), + ProvisionStep( + id: 'deploy_webrtc', + title: appText('部署 WebRTC 远端桌面', 'Deploy WebRTC remote desktop'), + phaseGroup: 'console', + ), + ProvisionStep( + id: 'deploy_bridge', + title: appText('部署 XWorkmate Bridge', 'Deploy XWorkmate Bridge'), + phaseGroup: 'bridge', + ), + ProvisionStep( + id: 'config_caddy', + title: appText('配置 Caddy / TLS', 'Configure Caddy / TLS'), + phaseGroup: 'bridge', + ), + ProvisionStep( + id: 'config_gateway', + title: appText('配置 OpenClaw Gateway', 'Configure OpenClaw Gateway'), + phaseGroup: 'bridge', + ), + ProvisionStep( + id: 'start_services', + title: appText('启动系统服务', 'Start system services'), + phaseGroup: 'bridge', + ), + ]; +} diff --git a/pubspec.lock b/pubspec.lock index 07740240..ce235149 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.7.0" + asn1lib: + dependency: transitive + description: + name: asn1lib + sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024" + url: "https://pub.dev" + source: hosted + version: "1.6.5" async: dependency: transitive description: @@ -57,6 +65,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" cross_file: dependency: transitive description: @@ -105,6 +121,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.8.1" + dartssh2: + dependency: "direct main" + description: + name: dartssh2 + sha256: c139babed0d6851449100010639115e1ed88decf0db7eb714ce13935c7eb590c + url: "https://pub.dev" + source: hosted + version: "2.17.1" device_info_plus: dependency: "direct main" description: @@ -523,6 +547,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + pinenacl: + dependency: transitive + description: + name: pinenacl + sha256: "57e907beaacbc3c024a098910b6240758e899674de07d6949a67b52fd984cbdf" + url: "https://pub.dev" + source: hosted + version: "0.6.0" pixel_snap: dependency: transitive description: @@ -547,6 +579,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + url: "https://pub.dev" + source: hosted + version: "4.0.0" process: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4db29340..acb2f7c7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,6 +17,7 @@ dependencies: cupertino_icons: ^1.0.8 cryptography: ^2.6.1 crypto: ^3.0.6 + dartssh2: ^2.17.1 device_info_plus: ^11.5.0 file_selector: ^1.0.3 flutter_html: ^3.0.0 diff --git a/test/features/settings/settings_remote_desktop_panel_test.dart b/test/features/settings/settings_remote_desktop_panel_test.dart index 96c8054e..544ea1f3 100644 --- a/test/features/settings/settings_remote_desktop_panel_test.dart +++ b/test/features/settings/settings_remote_desktop_panel_test.dart @@ -31,6 +31,7 @@ void main() { // Verify the panel headers and titles expect(find.text('AI工作空间'), findsOneWidget); expect(find.text('连接AI工作空间'), findsOneWidget); + expect(find.text('工作空间管理'), findsOneWidget); // Verify advanced options are hidden initially expect(find.text('GPU 加速'), findsNothing); @@ -43,6 +44,7 @@ void main() { expect(find.text('GPU 加速'), findsOneWidget); expect(find.widgetWithText(TextField, 'Display'), findsOneWidget); expect(find.text('Display'), findsOneWidget); + expect(find.text('工作空间管理'), findsOneWidget); }); }); } diff --git a/test/features/workspace_management/workspace_management_unit_test.dart b/test/features/workspace_management/workspace_management_unit_test.dart new file mode 100644 index 00000000..c04054e9 --- /dev/null +++ b/test/features/workspace_management/workspace_management_unit_test.dart @@ -0,0 +1,220 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/features/workspace_management/playbook_runner.dart'; +import 'package:xworkmate/features/workspace_management/server_detector.dart'; +import 'package:xworkmate/features/workspace_management/ssh_executor.dart'; +import 'package:xworkmate/features/workspace_management/workspace_provision_controller.dart'; +import 'package:xworkmate/features/workspace_management/workspace_provision_models.dart'; + +void main() { + group('workspace management models and parsers', () { + test('default steps include the v1 provisioning flow', () { + final steps = defaultProvisionSteps(); + + expect(steps.map((step) => step.id), [ + 'ssh_connect', + 'detect_env', + 'install_deps', + 'deploy_webrtc', + 'deploy_bridge', + 'config_caddy', + 'config_gateway', + 'start_services', + ]); + expect(steps.first.status, StepStatus.pending); + }); + + test('log buffer keeps only the newest lines', () { + final buffer = ProvisionLogBuffer(maxLines: 2); + + buffer.add('one'); + buffer.add('two'); + buffer.add('three'); + + expect(buffer.lines.length, 2); + expect(buffer.text, contains('two')); + expect(buffer.text, contains('three')); + expect(buffer.text, isNot(contains('one'))); + }); + + test('server detector parses command output', () { + final info = ServerDetector.parseServerInfo(''' +OS=Ubuntu 22.04.4 LTS +ARCH=x86_64 +SUDO=yes +DOCKER=missing +SYSTEMD=systemd 249 +CADDY=missing +ANSIBLE=missing +GIT=git version 2.34.1 +DNS_OK=1 +PORT_443_LISTENERS=0 +PORT_443_OPEN=yes +'''); + + expect(info.os, 'Ubuntu 22.04.4 LTS'); + expect(info.arch, 'x86_64'); + expect(info.sudoAvailable, isTrue); + expect(info.ansibleMissing, isTrue); + expect(info.gitMissing, isFalse); + expect(info.dnsResolved, isTrue); + expect(info.port443Open, isTrue); + expect(info.isPort443Available, isTrue); + }); + + test('ansible parser maps human readable output to step events', () { + final parser = AnsibleOutputParser(); + + final start = parser.parseLine('TASK [Configure caddy TLS]'); + final ok = parser.parseLine('changed: [localhost]'); + + expect(start?.stepId, 'config_caddy'); + expect(start?.status, StepStatus.running); + expect(ok?.stepId, 'config_caddy'); + expect(ok?.status, StepStatus.success); + }); + + test('detection command quotes workspace domain', () { + final command = ServerDetector.detectionCommand("a'b.example.com"); + + expect(command, contains("'a'\"'\"'b.example.com'")); + expect(command, contains('getent hosts')); + }); + }); + + group('WorkspaceProvisionController', () { + test('detectServer moves to ready with parsed server info', () async { + final controller = WorkspaceProvisionController( + executor: _FakeSshExecutor( + commandResults: [ + const SshResult( + exitCode: 0, + stdout: ''' +OS=Ubuntu 24.04 LTS +ARCH=x86_64 +SUDO=yes +DOCKER=missing +SYSTEMD=systemd 255 +CADDY=missing +ANSIBLE=ansible [core 2.16] +GIT=git version 2.43.0 +DNS_OK=1 +PORT_443_LISTENERS=0 +PORT_443_OPEN=yes +''', + stderr: '', + ), + ], + ), + ); + addTearDown(controller.dispose); + controller.updateForm( + serverAddress: '203.0.113.10', + workspaceDomain: 'workspace.example.com', + sshKeyContent: 'key', + ); + + await controller.detectServer(); + + expect(controller.phase, ProvisionPhase.ready); + expect(controller.serverInfo?.os, 'Ubuntu 24.04 LTS'); + expect( + controller.steps.firstWhere((step) => step.id == 'detect_env').status, + StepStatus.success, + ); + }); + + test('createWorkspace runs playbook flow with fake SSH', () async { + final executor = _FakeSshExecutor( + commandResults: [ + const SshResult(exitCode: 0, stdout: 'pulled', stderr: ''), + const SshResult(exitCode: 0, stdout: 'wrote', stderr: ''), + ], + streamingChunks: [ + 'TASK [Install desktop packages]\nok: [localhost]\n', + 'TASK [Configure caddy TLS]\nchanged: [localhost]\n', + ], + ); + final controller = WorkspaceProvisionController(executor: executor); + addTearDown(controller.dispose); + controller.updateForm( + serverAddress: '203.0.113.10', + workspaceDomain: 'workspace.example.com', + sshKeyContent: 'key', + ); + controller.serverInfo = const ServerInfo( + os: 'Ubuntu 22.04', + arch: 'x86_64', + sudoAvailable: true, + dockerVersion: 'missing', + systemdVersion: 'systemd 249', + caddyVersion: 'missing', + ansibleVersion: 'ansible [core 2.14]', + gitVersion: 'git version 2.34.1', + dnsAddressCount: 1, + port443ListenerCount: 0, + port443Open: true, + ); + + await controller.createWorkspace(); + + expect(controller.phase, ProvisionPhase.success); + expect(controller.deploymentResult?.url, 'https://workspace.example.com'); + expect(controller.deploymentResult?.bridgeToken, isNotEmpty); + expect(executor.commands.join('\n'), contains('ansible-playbook')); + }); + + test('precheck blocks when 443 is not open', () async { + final controller = WorkspaceProvisionController(executor: _FakeSshExecutor()); + addTearDown(controller.dispose); + controller.updateForm( + serverAddress: '203.0.113.10', + workspaceDomain: 'xworkmate-bridge.example.com', + sshKeyContent: 'key', + ); + controller.serverInfo = const ServerInfo( + os: 'Ubuntu 22.04', + arch: 'x86_64', + sudoAvailable: true, + dockerVersion: 'missing', + systemdVersion: 'systemd 249', + caddyVersion: 'missing', + ansibleVersion: 'ansible [core 2.14]', + gitVersion: 'git version 2.34.1', + dnsAddressCount: 1, + port443ListenerCount: 0, + port443Open: false, + ); + + expect( + controller.validatePrecheckBlockingIssue(), + contains('443'), + ); + }); + }); +} + +class _FakeSshExecutor implements WorkspaceSshExecutor { + _FakeSshExecutor({ + this.commandResults = const [], + this.streamingChunks = const [], + }); + + final List commandResults; + final List streamingChunks; + final List commands = []; + int _commandIndex = 0; + + @override + Future execute(SshConfig config, String command) async { + commands.add(command); + return commandResults[_commandIndex++]; + } + + @override + Stream executeStreaming(SshConfig config, String command) async* { + commands.add(command); + for (final chunk in streamingChunks) { + yield chunk; + } + } +} diff --git a/test/features/workspace_management/workspace_management_widget_test.dart b/test/features/workspace_management/workspace_management_widget_test.dart new file mode 100644 index 00000000..225203d6 --- /dev/null +++ b/test/features/workspace_management/workspace_management_widget_test.dart @@ -0,0 +1,180 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/app/app_controller.dart'; +import 'package:xworkmate/features/workspace_management/ssh_executor.dart'; +import 'package:xworkmate/features/workspace_management/workspace_management_panel.dart'; +import 'package:xworkmate/features/workspace_management/workspace_provision_controller.dart'; +import 'package:xworkmate/features/workspace_management/workspace_provision_models.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; +import 'package:xworkmate/runtime/secure_config_store.dart'; +import 'package:xworkmate/theme/app_theme.dart'; + +void main() { + testWidgets('panel renders form controls and keeps upgrade disabled', ( + tester, + ) async { + final appController = _NoopAppController(store: _MemorySecureConfigStore()); + final provisionController = WorkspaceProvisionController( + executor: _FakeSshExecutor(), + initialWorkspaceDomain: 'workspace.example.com', + ); + addTearDown(() { + provisionController.dispose(); + appController.dispose(); + }); + + await tester.pumpWidget( + _buildApp( + WorkspaceManagementPanel( + appController: appController, + provisionController: provisionController, + ), + ), + ); + + expect(find.text('创建 / 升级 AI 工作空间'), findsOneWidget); + expect(find.text('workspace.example.com'), findsOneWidget); + expect(find.byKey(const Key('workspace-management-upgrade-button')), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('workspace-management-upgrade-button')), + ) + .onPressed, + isNull, + ); + + await tester.tap(find.text('高级选项')); + await tester.pumpAndSettle(); + + expect(find.text('安装路径'), findsOneWidget); + }); + + testWidgets('panel switches auth method and expands logs', (tester) async { + final appController = _NoopAppController(store: _MemorySecureConfigStore()); + final provisionController = WorkspaceProvisionController( + executor: _FakeSshExecutor(), + ); + addTearDown(() { + provisionController.dispose(); + appController.dispose(); + }); + + await tester.pumpWidget( + _buildApp( + WorkspaceManagementPanel( + appController: appController, + provisionController: provisionController, + ), + ), + ); + + await tester.tap(find.text('密码')); + await tester.pumpAndSettle(); + expect(find.text('SSH 密码 *'), findsOneWidget); + + provisionController.logBuffer.add('hello log'); + provisionController.updateForm(logsExpanded: true); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('workspace-management-log-content')), findsOneWidget); + expect(find.textContaining('hello log'), findsOneWidget); + }); + + testWidgets('success result shows url and bridge token', (tester) async { + final appController = _NoopAppController(store: _MemorySecureConfigStore()); + final provisionController = WorkspaceProvisionController( + executor: _FakeSshExecutor(), + ); + addTearDown(() { + provisionController.dispose(); + appController.dispose(); + }); + provisionController.deploymentResult = const WorkspaceDeploymentResult( + url: 'https://xworkmate-bridge.example.com', + bridgeToken: 'bridge-token-123', + ); + provisionController.phase = ProvisionPhase.success; + + await tester.pumpWidget( + _buildApp( + WorkspaceManagementPanel( + appController: appController, + provisionController: provisionController, + ), + ), + ); + + expect(find.text('https://xworkmate-bridge.example.com'), findsOneWidget); + expect(find.text('bridge-token-123'), findsOneWidget); + expect(find.text('下载凭据'), findsOneWidget); + }); +} + +Widget _buildApp(Widget child) { + return MaterialApp( + theme: AppTheme.light(), + home: Material(child: child), + ); +} + +class _FakeSshExecutor implements WorkspaceSshExecutor { + @override + Future execute(SshConfig config, String command) async { + return const SshResult(exitCode: 0, stdout: '', stderr: ''); + } + + @override + Stream executeStreaming(SshConfig config, String command) async* {} +} + +class _NoopAppController extends AppController { + _NoopAppController({required SecureConfigStore store}) + : super(environmentOverride: const {}, store: store); +} + +class _MemorySecureConfigStore extends SecureConfigStore { + _MemorySecureConfigStore() : super(enableSecureStorage: false); + + SettingsSnapshot _settings = SettingsSnapshot.defaults(); + final Map _secrets = {}; + + @override + Future initialize() async {} + + @override + Future loadSettingsSnapshot() async => _settings; + + @override + Future saveSettingsSnapshot(SettingsSnapshot snapshot) async { + _settings = snapshot; + } + + @override + Future> loadSecureRefs() async => _secrets; + + @override + Future> loadAuditTrail() async => + const []; + + @override + Future appendAudit(SecretAuditEntry entry) async {} + + @override + Future loadSecretValueByRef(String refName) async => + _secrets[refName]; + + @override + Future saveSecretValueByRef(String refName, String value) async { + _secrets[refName] = value; + } + + @override + Future loadAccountSessionToken() async => null; + + @override + Future loadAccountSessionSummary() async => null; + + @override + Future loadAccountSyncState() async => null; +} From 018fa551ddc6b9c5fc65c13aa00e2255dcecf324 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 8 Jun 2026 13:20:41 +0800 Subject: [PATCH 02/15] Fix gateway dispatch test pipeline --- lib/app/app_controller_desktop_core.dart | 11 +- ...app_controller_desktop_thread_actions.dart | 8 ++ lib/runtime/code_agent_node_orchestrator.dart | 3 +- lib/runtime/runtime_coordinator.dart | 55 +++++--- .../assistant_execution_target_test.dart | 117 ++++++++++++++++++ 5 files changed, 174 insertions(+), 20 deletions(-) diff --git a/lib/app/app_controller_desktop_core.dart b/lib/app/app_controller_desktop_core.dart index 2a0465a1..c4ff515d 100644 --- a/lib/app/app_controller_desktop_core.dart +++ b/lib/app/app_controller_desktop_core.dart @@ -23,6 +23,7 @@ import '../runtime/settings_store.dart'; import '../runtime/secure_config_store.dart'; import '../runtime/embedded_agent_launch_policy.dart'; import '../runtime/runtime_coordinator.dart'; +import '../runtime/runtime_dispatch_resolver.dart'; import '../runtime/gateway_acp_client.dart'; import '../runtime/codex_runtime.dart'; import '../runtime/codex_config_bridge.dart'; @@ -67,6 +68,7 @@ class AppController extends ChangeNotifier { AccountRuntimeClient Function(String baseUrl)? accountClientFactory, Map? environmentOverride, GoTaskServiceClient? goTaskServiceClient, + RuntimeDispatchResolver? dispatchResolver, }) { environmentOverrideInternal = environmentOverride == null ? null @@ -145,10 +147,11 @@ class AppController extends ChangeNotifier { desktopPlatformServiceInternal = desktopPlatformService ?? createDesktopPlatformService(); runtimeCoordinatorInternal.attachDispatchResolver( - GoRuntimeDispatchDesktopClient( - client: gatewayAcpClientInternal, - endpointResolver: resolveGatewayAcpEndpointInternal, - ), + dispatchResolver ?? + GoRuntimeDispatchDesktopClient( + client: gatewayAcpClientInternal, + endpointResolver: resolveGatewayAcpEndpointInternal, + ), ); goTaskServiceClientInternal = goTaskServiceClient ?? diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index 42c36773..59bf00ad 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -933,6 +933,14 @@ extension AppControllerDesktopThreadActions on AppController { ..writeln('TaskThread workspace context:') ..writeln('- sessionKey: $sessionKey') ..writeln('- currentTaskWorkspace: $currentTaskWorkspace'); + if (workingDirectory.trim().isNotEmpty) { + buffer.writeln('- localWorkspace: ${workingDirectory.trim()}'); + } + if (remoteWorkingDirectoryHint.trim().isNotEmpty) { + buffer.writeln( + '- remoteWorkspaceHint: ${remoteWorkingDirectoryHint.trim()}', + ); + } final visibleTaskInputAttachments = taskInputAttachments .where((item) => item.name.trim().isNotEmpty && item.key.isNotEmpty) .toList(growable: false); diff --git a/lib/runtime/code_agent_node_orchestrator.dart b/lib/runtime/code_agent_node_orchestrator.dart index 74239675..616cf10d 100644 --- a/lib/runtime/code_agent_node_orchestrator.dart +++ b/lib/runtime/code_agent_node_orchestrator.dart @@ -70,7 +70,8 @@ class CodeAgentNodeOrchestrator { metadata: resolution.metadata, ); } - } catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace'); + } catch (e, stackTrace) { + debugPrint('Error: $e\n$stackTrace'); // Dispatch metadata is advisory; task execution still carries routing. } } diff --git a/lib/runtime/runtime_coordinator.dart b/lib/runtime/runtime_coordinator.dart index e07c26e5..967efc8e 100644 --- a/lib/runtime/runtime_coordinator.dart +++ b/lib/runtime/runtime_coordinator.dart @@ -64,9 +64,15 @@ class RuntimeCoordinator extends ChangeNotifier { } final normalizedCommand = provider.command.trim(); if (normalizedCommand.isEmpty) { - throw ArgumentError.value(provider.command, 'provider.command', 'Cannot be empty'); + throw ArgumentError.value( + provider.command, + 'provider.command', + 'Cannot be empty', + ); } - final normalizedCapabilities = _normalizeCapabilitySet(provider.capabilities).toList(growable: false)..sort(); + final normalizedCapabilities = _normalizeCapabilitySet( + provider.capabilities, + ).toList(growable: false)..sort(); _externalCodeAgents[normalizedId] = ExternalCodeAgentProvider( id: normalizedId, @@ -96,7 +102,8 @@ class RuntimeCoordinator extends ChangeNotifier { Iterable requiredCapabilities = const [], }) { final required = _normalizeCapabilitySet(requiredCapabilities); - final providers = _externalCodeAgents.values + final providers = + _externalCodeAgents.values .where((provider) => _providerSupports(provider, required)) .toList(growable: false) ..sort((a, b) => a.id.compareTo(b.id)); @@ -147,7 +154,9 @@ class RuntimeCoordinator extends ChangeNotifier { } } - final discovered = discoverExternalCodeAgents(requiredCapabilities: required); + final discovered = discoverExternalCodeAgents( + requiredCapabilities: required, + ); if (discovered.isEmpty) { return null; } @@ -250,7 +259,9 @@ class RuntimeCoordinator extends ChangeNotifier { } Future stopCodeAgentRuntime() async { - _state = gateway.isConnected ? CoordinatorState.ready : CoordinatorState.disconnected; + _state = gateway.isConnected + ? CoordinatorState.ready + : CoordinatorState.disconnected; notifyListeners(); } @@ -264,12 +275,18 @@ class RuntimeCoordinator extends ChangeNotifier { bool supportsCapability(String capability) { switch (capability) { - case 'cloud-memory': return capabilities.hasCloudMemory; - case 'task-queue': return capabilities.hasTaskQueue; - case 'multi-agent': return capabilities.hasMultiAgent; - case 'local-models': return capabilities.hasLocalModels; - case 'code-agent': return capabilities.hasCodeAgent; - default: return false; + case 'cloud-memory': + return capabilities.hasCloudMemory; + case 'task-queue': + return capabilities.hasTaskQueue; + case 'multi-agent': + return capabilities.hasMultiAgent; + case 'local-models': + return capabilities.hasLocalModels; + case 'code-agent': + return capabilities.hasCodeAgent; + default: + return false; } } @@ -295,16 +312,24 @@ class RuntimeCoordinator extends ChangeNotifier { Future _switchMode(GatewayMode mode) { switch (mode) { - case GatewayMode.remote: return modeSwitcher.switchToRemote(); - case GatewayMode.offline: return modeSwitcher.switchToOffline(); + case GatewayMode.remote: + return modeSwitcher.switchToRemote(); + case GatewayMode.offline: + return modeSwitcher.switchToOffline(); } } static Set _normalizeCapabilitySet(Iterable capabilities) { - return capabilities.map((item) => item.trim().toLowerCase()).where((item) => item.isNotEmpty).toSet(); + return capabilities + .map((item) => item.trim().toLowerCase()) + .where((item) => item.isNotEmpty) + .toSet(); } - static bool _providerSupports(ExternalCodeAgentProvider provider, Set requiredCapabilities) { + static bool _providerSupports( + ExternalCodeAgentProvider provider, + Set requiredCapabilities, + ) { if (requiredCapabilities.isEmpty) return true; final provided = _normalizeCapabilitySet(provider.capabilities); return requiredCapabilities.every(provided.contains); diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index bbf0accc..166f59be 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -12,6 +12,7 @@ import 'package:xworkmate/features/assistant/assistant_page_composer_skill_picke import 'package:xworkmate/runtime/gateway_acp_client.dart'; import 'package:xworkmate/runtime/go_task_service_client.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; +import 'package:xworkmate/runtime/runtime_dispatch_resolver.dart'; import 'package:xworkmate/runtime/secure_config_store.dart'; import 'package:xworkmate/runtime/runtime_coordinator.dart'; import 'package:xworkmate/runtime/desktop_platform_service.dart'; @@ -1645,6 +1646,7 @@ void main() { 'continue with the same image', attachments: [imageAttachment], ); + await fakeGoTaskService.waitForRequestCount(2); expect(fakeGoTaskService.requests, hasLength(2)); expect( @@ -4608,6 +4610,7 @@ AppController _sandboxController({ 'HOME': actualHome, }, goTaskServiceClient: goTaskServiceClient, + dispatchResolver: _NoopRuntimeDispatchResolver(), ); } @@ -4847,6 +4850,16 @@ class _RecordingGoTaskServiceClient implements GoTaskServiceClient { ); } + Future waitForRequestCount(int count) async { + final deadline = DateTime.now().add(const Duration(seconds: 15)); + while (requests.length < count && DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 10)); + } + if (requests.length < count) { + throw StateError('Timed out waiting for $count requests.'); + } + } + @override Future getTask({ required AssistantExecutionTarget target, @@ -5008,3 +5021,107 @@ class _BlockingGoTaskServiceClient implements GoTaskServiceClient { @override Future dispose() async {} } + +class _NoopRuntimeDispatchResolver implements RuntimeDispatchResolver { + @override + Future resolveGatewayDispatch({ + required List providers, + required String preferredProviderId, + required Iterable requiredCapabilities, + required Map nodeState, + required Map nodeInfo, + }) async { + final selectedProvider = _selectLocalProvider( + providers, + preferredProviderId, + requiredCapabilities, + ); + final metadata = { + 'node': { + 'id': nodeInfo['id']?.toString() ?? 'xworkmate-app', + 'name': nodeInfo['name']?.toString() ?? '', + 'version': nodeInfo['version']?.toString() ?? '', + 'kind': 'app-mediated-cooperative-node', + 'gatewayTransport': 'websocket-rpc', + }, + 'dispatch': { + 'mode': nodeState['bridgeEnabled'] == true + ? 'cooperative' + : 'gateway-only', + 'executionTarget': nodeState['executionTarget']?.toString() ?? '', + }, + 'bridge': { + 'enabled': nodeState['bridgeEnabled'] == true, + 'state': nodeState['bridgeState']?.toString() ?? '', + 'gatewayConnected': nodeState['gatewayConnected'] == true, + 'runtimeMode': nodeState['runtimeMode']?.toString() ?? '', + 'localTransport': 'stdio-jsonrpc', + }, + if (selectedProvider != null) + 'provider': { + 'id': selectedProvider.id, + 'name': selectedProvider.name, + 'defaultArgs': selectedProvider.defaultArgs, + 'capabilities': selectedProvider.capabilities, + }, + }; + return RuntimeDispatchResolution( + agentId: + nodeState['selectedAgentId']?.toString().trim().isNotEmpty == true + ? nodeState['selectedAgentId'].toString().trim() + : null, + providerId: selectedProvider?.id, + metadata: metadata, + raw: metadata, + ); + } + + @override + Future selectProviderId({ + required List providers, + String preferredProviderId = '', + Iterable requiredCapabilities = const [], + }) async { + return null; + } + + @override + Future dispose() async {} + + ExternalCodeAgentProvider? _selectLocalProvider( + List providers, + String preferredProviderId, + Iterable requiredCapabilities, + ) { + final required = requiredCapabilities + .map((item) => item.trim().toLowerCase()) + .where((item) => item.isNotEmpty) + .toSet(); + + bool supports(ExternalCodeAgentProvider provider) { + if (required.isEmpty) { + return true; + } + final capabilities = provider.capabilities + .map((item) => item.trim().toLowerCase()) + .where((item) => item.isNotEmpty) + .toSet(); + return required.every(capabilities.contains); + } + + final normalizedPreferred = preferredProviderId.trim(); + if (normalizedPreferred.isNotEmpty) { + for (final provider in providers) { + if (provider.id == normalizedPreferred && supports(provider)) { + return provider; + } + } + } + for (final provider in providers) { + if (supports(provider)) { + return provider; + } + } + return null; + } +} From 26ee21576573415d66e6699fc33ce162b2d93d8f Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 8 Jun 2026 15:52:21 +0800 Subject: [PATCH 03/15] Harden workspace prechecks --- .../workspace_provision_controller.dart | 6 ++++ .../workspace_management_unit_test.dart | 28 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/lib/features/workspace_management/workspace_provision_controller.dart b/lib/features/workspace_management/workspace_provision_controller.dart index 4daced08..6d13c318 100644 --- a/lib/features/workspace_management/workspace_provision_controller.dart +++ b/lib/features/workspace_management/workspace_provision_controller.dart @@ -265,6 +265,12 @@ class WorkspaceProvisionController extends ChangeNotifier { if (info == null) { return null; } + if (!info.os.toLowerCase().contains('ubuntu')) { + return appText( + '当前仅支持 Ubuntu 20.04 / 22.04 / 24.04,检测到 ${info.os}。', + 'Only Ubuntu 20.04 / 22.04 / 24.04 is supported. Detected: ${info.os}.', + ); + } if (!info.dnsResolved) { return appText( '部署前需要先把 ${workspaceDomain.trim()} 做好 DNS 解析。', diff --git a/test/features/workspace_management/workspace_management_unit_test.dart b/test/features/workspace_management/workspace_management_unit_test.dart index c04054e9..d7dbb36b 100644 --- a/test/features/workspace_management/workspace_management_unit_test.dart +++ b/test/features/workspace_management/workspace_management_unit_test.dart @@ -190,6 +190,34 @@ PORT_443_OPEN=yes contains('443'), ); }); + + test('precheck blocks unsupported non-Ubuntu systems', () async { + final controller = WorkspaceProvisionController(executor: _FakeSshExecutor()); + addTearDown(controller.dispose); + controller.updateForm( + serverAddress: '203.0.113.10', + workspaceDomain: 'xworkmate-bridge.example.com', + sshKeyContent: 'key', + ); + controller.serverInfo = const ServerInfo( + os: 'Debian GNU/Linux 11 (bullseye)', + arch: 'x86_64', + sudoAvailable: true, + dockerVersion: 'missing', + systemdVersion: 'systemd 249', + caddyVersion: 'missing', + ansibleVersion: 'ansible [core 2.14]', + gitVersion: 'git version 2.34.1', + dnsAddressCount: 1, + port443ListenerCount: 0, + port443Open: true, + ); + + expect( + controller.validatePrecheckBlockingIssue(), + contains('Ubuntu'), + ); + }); }); } From 6d527a2618024ab05d31dc27d108a443f76103b7 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 8 Jun 2026 16:14:08 +0800 Subject: [PATCH 04/15] Relax workspace OS checks and add YAML import/export --- .../workspace_management/playbook_runner.dart | 45 +++- .../workspace_management/server_detector.dart | 42 ++-- .../workspace_management_form.dart | 62 ++++++ .../workspace_management_panel.dart | 76 +++++++ .../workspace_provision_controller.dart | 192 ++++++++++++++++-- .../workspace_provision_models.dart | 11 + .../workspace_management_unit_test.dart | 119 ++++++++++- 7 files changed, 506 insertions(+), 41 deletions(-) diff --git a/lib/features/workspace_management/playbook_runner.dart b/lib/features/workspace_management/playbook_runner.dart index 31154558..05287097 100644 --- a/lib/features/workspace_management/playbook_runner.dart +++ b/lib/features/workspace_management/playbook_runner.dart @@ -18,10 +18,15 @@ class PlaybookRunner { required SshConfig ssh, required String action, required String workspaceDomain, + required String bridgeDomain, required String bridgeToken, required String installPath, required bool installMissingPrerequisites, required ServerInfo? serverInfo, + String? deepseekApiKey, + String? nvidiaApiKey, + String? ollamaApiKey, + String? openclawGatewayToken, required void Function(String stepId, StepStatus status, String? message) onStepUpdate, required void Function(String logLine) onLog, @@ -38,7 +43,11 @@ class PlaybookRunner { var info = serverInfo; if (info == null) { onStepUpdate('ssh_connect', StepStatus.running, null); - info = await ServerDetector(executor).detect(ssh, workspaceDomain); + info = await ServerDetector(executor).detect( + ssh, + workspaceDomain, + bridgeDomain, + ); onStepUpdate('ssh_connect', StepStatus.success, null); onStepUpdate('detect_env', StepStatus.success, info.displaySummary); } @@ -68,7 +77,12 @@ class PlaybookRunner { inventoryPath: inventoryPath, varsPath: varsPath, workspaceDomain: workspaceDomain, + bridgeDomain: bridgeDomain, bridgeToken: bridgeToken, + deepseekApiKey: deepseekApiKey, + nvidiaApiKey: nvidiaApiKey, + ollamaApiKey: ollamaApiKey, + openclawGatewayToken: openclawGatewayToken, ), onLog, ); @@ -167,10 +181,27 @@ class PlaybookRunner { required String inventoryPath, required String varsPath, required String workspaceDomain, + required String bridgeDomain, required String bridgeToken, + String? deepseekApiKey, + String? nvidiaApiKey, + String? ollamaApiKey, + String? openclawGatewayToken, }) { final domain = workspaceDomain.trim(); - final publicUrl = 'https://$domain'; + final bridge = bridgeDomain.trim(); + final bridgeUrl = 'https://$bridge'; + final extraEnvVars = [ + if ((deepseekApiKey ?? '').trim().isNotEmpty) + 'deepseek_api_key: ${shellQuote(deepseekApiKey!.trim())}', + if ((nvidiaApiKey ?? '').trim().isNotEmpty) + 'nvidia_api_key: ${shellQuote(nvidiaApiKey!.trim())}', + if ((ollamaApiKey ?? '').trim().isNotEmpty) + 'ollama_api_key: ${shellQuote(ollamaApiKey!.trim())}', + if ((openclawGatewayToken ?? '').trim().isNotEmpty) + 'openclaw_gateway_token: ${shellQuote(openclawGatewayToken!.trim())}', + ]; + final extraEnvBlock = extraEnvVars.isEmpty ? '' : '${extraEnvVars.join('\n')}\n'; return ''' cat > ${shellQuote(inventoryPath)} <<'EOF' [all] @@ -178,12 +209,12 @@ localhost ansible_connection=local EOF cat > ${shellQuote(varsPath)} <<'EOF' workspace_domain: $domain -xworkmate_bridge_domain: $domain -xworkmate_bridge_public_base_url: $publicUrl -xworkmate_bridge_service_domain: $domain -xworkmate_bridge_service_public_base_url: $publicUrl +xworkmate_bridge_domain: $bridge +xworkmate_bridge_public_base_url: $bridgeUrl +xworkmate_bridge_service_domain: $bridge +xworkmate_bridge_service_public_base_url: $bridgeUrl xworkmate_bridge_auth_token: ${bridgeToken.trim()} -EOF +${extraEnvBlock}EOF '''; } diff --git a/lib/features/workspace_management/server_detector.dart b/lib/features/workspace_management/server_detector.dart index 3d3ac450..3adc360d 100644 --- a/lib/features/workspace_management/server_detector.dart +++ b/lib/features/workspace_management/server_detector.dart @@ -6,10 +6,14 @@ class ServerDetector { final WorkspaceSshExecutor executor; - Future detect(SshConfig ssh, String workspaceDomain) async { + Future detect( + SshConfig ssh, + String workspaceDomain, + String bridgeDomain, + ) async { final result = await executor.execute( ssh, - detectionCommand(workspaceDomain), + detectionCommand(workspaceDomain, bridgeDomain), ); if (!result.success) { throw ServerDetectionException(result.combinedOutput.trim()); @@ -17,8 +21,9 @@ class ServerDetector { return parseServerInfo(result.stdout); } - static String detectionCommand(String workspaceDomain) { + static String detectionCommand(String workspaceDomain, String bridgeDomain) { final domain = shellQuote(workspaceDomain.trim()); + final bridge = shellQuote(bridgeDomain.trim()); return ''' if command -v lsb_release >/dev/null 2>&1; then echo "OS=\$(lsb_release -ds)" @@ -35,14 +40,17 @@ echo "ANSIBLE=\$(ansible --version 2>/dev/null | head -1 || echo missing)" echo "GIT=\$(git --version 2>/dev/null || echo missing)" echo "DNS_OK=\$(getent hosts $domain 2>/dev/null | wc -l | tr -d ' ')" echo "PORT_443_LISTENERS=\$(ss -ltn '( sport = :443 )' 2>/dev/null | tail -n +2 | wc -l | tr -d ' ')" +echo "BRIDGE_DNS_OK=\$(getent hosts $bridge 2>/dev/null | wc -l | tr -d ' ')" +echo "BRIDGE_PORT_443_LISTENERS=\$(ss -ltn '( sport = :443 )' 2>/dev/null | tail -n +2 | wc -l | tr -d ' ')" +PORT_443_OPEN=yes if command -v ufw >/dev/null 2>&1; then UFW_STATUS="\$(ufw status 2>/dev/null || sudo -n ufw status 2>/dev/null || echo unavailable)" if printf '%s' "\$UFW_STATUS" | grep -qi 'Status: inactive'; then - echo "PORT_443_OPEN=yes" + PORT_443_OPEN=yes elif printf '%s' "\$UFW_STATUS" | grep -Eqi '(^|[[:space:]])(443(/tcp)?|https)[[:space:]]+ALLOW'; then - echo "PORT_443_OPEN=yes" + PORT_443_OPEN=yes else - echo "PORT_443_OPEN=no" + PORT_443_OPEN=no fi elif command -v firewall-cmd >/dev/null 2>&1; then FIREWALL_STATE="\$(firewall-cmd --state 2>/dev/null || sudo -n firewall-cmd --state 2>/dev/null || echo not-running)" @@ -51,16 +59,16 @@ if command -v ufw >/dev/null 2>&1; then sudo -n firewall-cmd --quiet --query-service=https 2>/dev/null || firewall-cmd --quiet --query-port=443/tcp 2>/dev/null || sudo -n firewall-cmd --quiet --query-port=443/tcp 2>/dev/null; then - echo "PORT_443_OPEN=yes" + PORT_443_OPEN=yes else - echo "PORT_443_OPEN=no" + PORT_443_OPEN=no fi else - echo "PORT_443_OPEN=yes" + PORT_443_OPEN=yes fi -else - echo "PORT_443_OPEN=yes" fi +echo "PORT_443_OPEN=\$PORT_443_OPEN" +echo "BRIDGE_PORT_443_OPEN=\$PORT_443_OPEN" '''; } @@ -83,9 +91,17 @@ fi ansibleVersion: values['ANSIBLE'] ?? 'missing', gitVersion: values['GIT'] ?? 'missing', dnsAddressCount: int.tryParse(values['DNS_OK'] ?? '') ?? 0, - port443ListenerCount: + port443ListenerCount: int.tryParse(values['PORT_443_LISTENERS'] ?? '') ?? 0, - port443Open: (values['PORT_443_OPEN'] ?? '').toLowerCase() != 'no', + port443Open: (values['PORT_443_OPEN'] ?? '').toLowerCase() != 'no', + bridgeDnsAddressCount: + int.tryParse(values['BRIDGE_DNS_OK'] ?? '') ?? 0, + bridgePort443ListenerCount: + int.tryParse(values['BRIDGE_PORT_443_LISTENERS'] ?? '') ?? 0, + bridgePort443Open: + (values['BRIDGE_PORT_443_OPEN'] ?? values['PORT_443_OPEN'] ?? '') + .toLowerCase() != + 'no', ); } } diff --git a/lib/features/workspace_management/workspace_management_form.dart b/lib/features/workspace_management/workspace_management_form.dart index 794e30dc..44cceb91 100644 --- a/lib/features/workspace_management/workspace_management_form.dart +++ b/lib/features/workspace_management/workspace_management_form.dart @@ -31,6 +31,10 @@ class _WorkspaceManagementFormState extends State { late final TextEditingController _portController; late final TextEditingController _sudoController; late final TextEditingController _installPathController; + late final TextEditingController _deepseekKeyController; + late final TextEditingController _nvidiaKeyController; + late final TextEditingController _ollamaKeyController; + late final TextEditingController _openclawTokenController; @override void initState() { @@ -45,6 +49,11 @@ class _WorkspaceManagementFormState extends State { _portController = TextEditingController(text: c.sshPort.toString()); _sudoController = TextEditingController(text: c.sudoPassword ?? ''); _installPathController = TextEditingController(text: c.installPath); + _deepseekKeyController = TextEditingController(text: c.deepseekApiKey ?? ''); + _nvidiaKeyController = TextEditingController(text: c.nvidiaApiKey ?? ''); + _ollamaKeyController = TextEditingController(text: c.ollamaApiKey ?? ''); + _openclawTokenController = + TextEditingController(text: c.openclawGatewayToken ?? ''); } @override @@ -58,6 +67,10 @@ class _WorkspaceManagementFormState extends State { _portController.dispose(); _sudoController.dispose(); _installPathController.dispose(); + _deepseekKeyController.dispose(); + _nvidiaKeyController.dispose(); + _ollamaKeyController.dispose(); + _openclawTokenController.dispose(); super.dispose(); } @@ -74,6 +87,10 @@ class _WorkspaceManagementFormState extends State { installPath: _installPathController.text.trim().isEmpty ? '/opt/xworkspace/playbooks' : _installPathController.text.trim(), + deepseekApiKey: _deepseekKeyController.text, + nvidiaApiKey: _nvidiaKeyController.text, + ollamaApiKey: _ollamaKeyController.text, + openclawGatewayToken: _openclawTokenController.text, ); } @@ -111,6 +128,19 @@ class _WorkspaceManagementFormState extends State { label: appText('Workspace 域名 *', 'Workspace domain *'), icon: Icons.public_outlined, ), + SizedBox( + width: itemWidth, + child: Padding( + padding: const EdgeInsets.only(left: 14, top: 2), + child: Text( + appText( + '将检测桥接域名:${controller.bridgeDomain}', + 'Bridge domain will be checked: ${controller.bridgeDomain}', + ), + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ), _field( width: itemWidth, controller: _userController, @@ -218,6 +248,38 @@ class _WorkspaceManagementFormState extends State { label: appText('安装路径', 'Install path'), icon: Icons.storage_outlined, ), + _field( + width: 320, + controller: _deepseekKeyController, + enabled: !disabled, + label: 'DEEPSEEK_API_KEY', + icon: Icons.key_outlined, + obscureText: true, + ), + _field( + width: 320, + controller: _nvidiaKeyController, + enabled: !disabled, + label: 'NVIDIA_API_KEY', + icon: Icons.key_outlined, + obscureText: true, + ), + _field( + width: 320, + controller: _ollamaKeyController, + enabled: !disabled, + label: 'OLLAMA_API_KEY', + icon: Icons.key_outlined, + obscureText: true, + ), + _field( + width: 320, + controller: _openclawTokenController, + enabled: !disabled, + label: 'OPENCLAW_GATEWAY_TOKEN', + icon: Icons.key_outlined, + obscureText: true, + ), ], ), ], diff --git a/lib/features/workspace_management/workspace_management_panel.dart b/lib/features/workspace_management/workspace_management_panel.dart index 5033da84..2c7e6fc2 100644 --- a/lib/features/workspace_management/workspace_management_panel.dart +++ b/lib/features/workspace_management/workspace_management_panel.dart @@ -108,6 +108,66 @@ class _WorkspaceManagementPanelState extends State { } } + Future _exportConfig() async { + final yaml = _controller.exportYaml(); + await Clipboard.setData(ClipboardData(text: yaml)); + if (!mounted) { + return; + } + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(appText('YAML 已导出', 'YAML exported')), + content: SingleChildScrollView(child: SelectableText(yaml)), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(appText('关闭', 'Close')), + ), + ], + ), + ); + } + + Future _importConfig() async { + final yamlController = TextEditingController(); + try { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(appText('导入 YAML', 'Import YAML')), + content: SizedBox( + width: 720, + child: TextField( + controller: yamlController, + minLines: 12, + maxLines: 18, + decoration: InputDecoration( + hintText: appText('粘贴 YAML 配置', 'Paste YAML configuration'), + alignLabelWithHint: true, + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(appText('取消', 'Cancel')), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(appText('导入', 'Import')), + ), + ], + ), + ); + if (confirmed == true) { + _controller.importYaml(yamlController.text); + } + } finally { + yamlController.dispose(); + } + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -139,6 +199,22 @@ class _WorkspaceManagementPanelState extends State { ), ), ), + TextButton.icon( + onPressed: _controller.isBusy + ? null + : () => unawaited(_exportConfig()), + icon: const Icon(Icons.upload_outlined), + label: Text(appText('导出 YAML', 'Export YAML')), + ), + const SizedBox(width: 8), + TextButton.icon( + onPressed: _controller.isBusy + ? null + : () => unawaited(_importConfig()), + icon: const Icon(Icons.download_outlined), + label: Text(appText('导入 YAML', 'Import YAML')), + ), + const SizedBox(width: 8), IconButton( onPressed: _controller.isBusy ? null diff --git a/lib/features/workspace_management/workspace_provision_controller.dart b/lib/features/workspace_management/workspace_provision_controller.dart index 6d13c318..340c054b 100644 --- a/lib/features/workspace_management/workspace_provision_controller.dart +++ b/lib/features/workspace_management/workspace_provision_controller.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:yaml/yaml.dart'; import '../../i18n/app_language.dart'; import 'playbook_runner.dart'; @@ -27,9 +28,15 @@ class WorkspaceProvisionController extends ChangeNotifier { int sshPort = 22; String? sudoPassword; String installPath = '/opt/xworkspace/playbooks'; + String? deepseekApiKey; + String? nvidiaApiKey; + String? ollamaApiKey; + String? openclawGatewayToken; bool showAdvanced = false; bool logsExpanded = false; + static const String redactedValue = '__redacted__'; + ProvisionPhase phase = ProvisionPhase.idle; late List steps; final ProvisionLogBuffer logBuffer = ProvisionLogBuffer(); @@ -40,6 +47,13 @@ class WorkspaceProvisionController extends ChangeNotifier { bool get isBusy => phase == ProvisionPhase.checking || phase == ProvisionPhase.running; + String get bridgeDomain => deriveBridgeDomain(workspaceDomain); + + String get bridgeBaseUrl { + final domain = bridgeDomain.trim(); + return domain.isEmpty ? '' : 'https://$domain'; + } + bool get canSubmit { final hasAuth = switch (authMethod) { AuthMethod.password => (sshPassword ?? '').trim().isNotEmpty, @@ -73,11 +87,12 @@ class WorkspaceProvisionController extends ChangeNotifier { return; } _prepareRun(ProvisionPhase.checking); - _setStep('ssh_connect', StepStatus.running, null); + _setStep('ssh_connect', StepStatus.running, null); try { final detected = await ServerDetector(executor).detect( sshConfig(), workspaceDomain.trim(), + bridgeDomain, ); serverInfo = detected; _setStep('ssh_connect', StepStatus.success, null); @@ -111,6 +126,7 @@ class WorkspaceProvisionController extends ChangeNotifier { final detected = await ServerDetector(executor).detect( sshConfig(), workspaceDomain.trim(), + bridgeDomain, ); serverInfo = detected; } @@ -128,7 +144,12 @@ class WorkspaceProvisionController extends ChangeNotifier { ssh: sshConfig(), action: 'create', workspaceDomain: workspaceDomain.trim(), + bridgeDomain: bridgeDomain, bridgeToken: bridgeToken, + deepseekApiKey: deepseekApiKey, + nvidiaApiKey: nvidiaApiKey, + ollamaApiKey: ollamaApiKey, + openclawGatewayToken: openclawGatewayToken, installPath: installPath.trim(), installMissingPrerequisites: installMissingPrerequisites, serverInfo: serverInfo, @@ -142,7 +163,7 @@ class WorkspaceProvisionController extends ChangeNotifier { } phase = ProvisionPhase.success; deploymentResult = WorkspaceDeploymentResult( - url: 'https://${workspaceDomain.trim()}', + url: bridgeBaseUrl, bridgeToken: bridgeToken, ); errorMessage = null; @@ -185,6 +206,10 @@ class WorkspaceProvisionController extends ChangeNotifier { int? sshPort, String? sudoPassword, String? installPath, + String? deepseekApiKey, + String? nvidiaApiKey, + String? ollamaApiKey, + String? openclawGatewayToken, bool? showAdvanced, bool? logsExpanded, }) { @@ -198,11 +223,74 @@ class WorkspaceProvisionController extends ChangeNotifier { this.sshPort = sshPort ?? this.sshPort; this.sudoPassword = sudoPassword ?? this.sudoPassword; this.installPath = installPath ?? this.installPath; + this.deepseekApiKey = deepseekApiKey ?? this.deepseekApiKey; + this.nvidiaApiKey = nvidiaApiKey ?? this.nvidiaApiKey; + this.ollamaApiKey = ollamaApiKey ?? this.ollamaApiKey; + this.openclawGatewayToken = + openclawGatewayToken ?? this.openclawGatewayToken; this.showAdvanced = showAdvanced ?? this.showAdvanced; this.logsExpanded = logsExpanded ?? this.logsExpanded; notifyListeners(); } + String exportYaml() { + final data = { + 'server_address': serverAddress.trim(), + 'workspace_domain': workspaceDomain.trim(), + 'ssh_username': sshUsername.trim(), + 'auth_method': authMethod.name, + 'ssh_port': sshPort, + 'install_path': installPath.trim(), + 'show_advanced': showAdvanced, + 'logs_expanded': logsExpanded, + 'ssh_password': redact(sshPassword), + 'ssh_key_content': redact(sshKeyContent), + 'ssh_key_path': redact(sshKeyPath), + 'sudo_password': redact(sudoPassword), + 'deepseek_api_key': redact(deepseekApiKey), + 'nvidia_api_key': redact(nvidiaApiKey), + 'ollama_api_key': redact(ollamaApiKey), + 'openclaw_gateway_token': redact(openclawGatewayToken), + }; + final buffer = StringBuffer(); + for (final entry in data.entries) { + buffer.writeln('${entry.key}: ${yamlScalar(entry.value)}'); + } + return buffer.toString().trimRight(); + } + + void importYaml(String raw) { + final decoded = loadYaml(raw); + if (decoded is! YamlMap) { + throw const FormatException('Invalid YAML document'); + } + final map = {}; + for (final entry in decoded.nodes.entries) { + map['${entry.key.value}'] = entry.value.value; + } + updateForm( + serverAddress: stringValue(map['server_address']), + workspaceDomain: stringValue(map['workspace_domain']), + sshUsername: stringValue(map['ssh_username']), + authMethod: parseAuthMethod(map['auth_method']), + sshPassword: secretValue(map['ssh_password'], sshPassword), + sshKeyContent: secretValue(map['ssh_key_content'], sshKeyContent), + sshKeyPath: secretValue(map['ssh_key_path'], sshKeyPath), + sshPort: intValue(map['ssh_port'], sshPort), + sudoPassword: secretValue(map['sudo_password'], sudoPassword), + installPath: stringValue(map['install_path']), + deepseekApiKey: secretValue(map['deepseek_api_key'], deepseekApiKey), + nvidiaApiKey: secretValue(map['nvidia_api_key'], nvidiaApiKey), + ollamaApiKey: secretValue(map['ollama_api_key'], ollamaApiKey), + openclawGatewayToken: secretValue( + map['openclaw_gateway_token'], + openclawGatewayToken, + ), + showAdvanced: boolValue(map['show_advanced'], showAdvanced), + logsExpanded: boolValue(map['logs_expanded'], logsExpanded), + ); + } + void _prepareRun(ProvisionPhase nextPhase, {bool keepDetection = false}) { phase = nextPhase; errorMessage = null; @@ -251,7 +339,7 @@ class WorkspaceProvisionController extends ChangeNotifier { String ensureBridgeToken() { deploymentResult ??= WorkspaceDeploymentResult( - url: 'https://${workspaceDomain.trim()}', + url: bridgeBaseUrl, bridgeToken: generateBridgeToken(), ); return deploymentResult!.bridgeToken; @@ -265,28 +353,29 @@ class WorkspaceProvisionController extends ChangeNotifier { if (info == null) { return null; } - if (!info.os.toLowerCase().contains('ubuntu')) { + final os = info.os.toLowerCase(); + if (!(os.contains('ubuntu') || os.contains('debian'))) { return appText( - '当前仅支持 Ubuntu 20.04 / 22.04 / 24.04,检测到 ${info.os}。', - 'Only Ubuntu 20.04 / 22.04 / 24.04 is supported. Detected: ${info.os}.', + '当前仅支持 Ubuntu / Debian 系列,检测到 ${info.os}。', + 'Only Ubuntu / Debian family systems are supported. Detected: ${info.os}.', ); } - if (!info.dnsResolved) { + if (!info.bridgeDnsResolved) { return appText( - '部署前需要先把 ${workspaceDomain.trim()} 做好 DNS 解析。', - 'Configure DNS for ${workspaceDomain.trim()} before deploying.', + '部署前需要先把 $bridgeDomain 做好 DNS 解析。', + 'Configure DNS for $bridgeDomain before deploying.', ); } - if (!info.port443Open) { + if (!info.bridgePort443Open) { return appText( - '目标服务器的 443 端口未开放,请先放通 HTTPS 访问。', - 'Port 443 is not open on the target server. Allow HTTPS traffic first.', + '$bridgeDomain 的 443 端口未开放,请先放通 HTTPS 访问。', + 'Port 443 is not open for $bridgeDomain. Allow HTTPS traffic first.', ); } - if (!info.isPort443Available) { + if (!info.isBridgePort443Available) { return appText( - '目标服务器的 443 端口已被占用,请先释放。', - 'Port 443 is already in use on the target server.', + '$bridgeDomain 的 443 端口已被占用,请先释放。', + 'Port 443 is already in use for $bridgeDomain.', ); } return null; @@ -297,8 +386,81 @@ class WorkspaceProvisionController extends ChangeNotifier { sshPassword = null; sshKeyContent = null; sudoPassword = null; + deepseekApiKey = null; + nvidiaApiKey = null; + ollamaApiKey = null; + openclawGatewayToken = null; super.dispose(); } + + static String deriveBridgeDomain(String input) { + final domain = input.trim().toLowerCase(); + if (domain.isEmpty) { + return ''; + } + if (domain.startsWith('xworkmate-bridge.')) { + return domain; + } + return 'xworkmate-bridge.$domain'; + } + + static String redact(String? value) { + final trimmed = value?.trim() ?? ''; + return trimmed.isEmpty ? '' : redactedValue; + } + + static String yamlScalar(Object? value) { + if (value == null) { + return '""'; + } + if (value is bool || value is num) { + return '$value'; + } + final text = '$value'; + if (text.isEmpty) { + return '""'; + } + if (text == redactedValue || text.contains(RegExp(r'[:#\n\r\t]')) || text.startsWith(' ') || text.endsWith(' ')) { + return '"${text.replaceAll('"', '\\"')}"'; + } + return text; + } + + static String stringValue(Object? value) { + final text = value?.toString().trim() ?? ''; + return text == redactedValue ? '' : text; + } + + static String? secretValue(Object? value, String? current) { + final text = value?.toString().trim() ?? ''; + if (text.isEmpty || text == redactedValue) { + return current; + } + return text; + } + + static int intValue(Object? value, int fallback) { + return int.tryParse(value?.toString().trim() ?? '') ?? fallback; + } + + static bool boolValue(Object? value, bool fallback) { + final text = value?.toString().trim().toLowerCase(); + if (text == null || text.isEmpty) { + return fallback; + } + if (text == 'true' || text == 'yes' || text == '1') { + return true; + } + if (text == 'false' || text == 'no' || text == '0') { + return false; + } + return fallback; + } + + static AuthMethod parseAuthMethod(Object? value) { + final text = value?.toString().trim().toLowerCase() ?? ''; + return text == 'password' ? AuthMethod.password : AuthMethod.sshKey; + } } class WorkspaceProvisionPrecheckException implements Exception { diff --git a/lib/features/workspace_management/workspace_provision_models.dart b/lib/features/workspace_management/workspace_provision_models.dart index f0af4d4c..2e7ee465 100644 --- a/lib/features/workspace_management/workspace_provision_models.dart +++ b/lib/features/workspace_management/workspace_provision_models.dart @@ -58,6 +58,9 @@ class ServerInfo { required this.dnsAddressCount, required this.port443ListenerCount, required this.port443Open, + required this.bridgeDnsAddressCount, + required this.bridgePort443ListenerCount, + required this.bridgePort443Open, }); final String os; @@ -71,12 +74,17 @@ class ServerInfo { final int dnsAddressCount; final int port443ListenerCount; final bool port443Open; + final int bridgeDnsAddressCount; + final int bridgePort443ListenerCount; + final bool bridgePort443Open; bool get gitMissing => _isMissing(gitVersion); bool get ansibleMissing => _isMissing(ansibleVersion); bool get hasMissingPrerequisites => gitMissing || ansibleMissing; bool get dnsResolved => dnsAddressCount > 0; bool get isPort443Available => port443ListenerCount == 0; + bool get bridgeDnsResolved => bridgeDnsAddressCount > 0; + bool get isBridgePort443Available => bridgePort443ListenerCount == 0; String get displaySummary { final sudo = sudoAvailable ? 'sudo=yes' : 'sudo=no'; @@ -87,6 +95,9 @@ class ServerInfo { dnsResolved ? 'dns=ok' : 'dns=missing', port443Open ? '443=open' : '443=blocked', isPort443Available ? '443=free' : '443=busy', + bridgeDnsResolved ? 'bridge-dns=ok' : 'bridge-dns=missing', + bridgePort443Open ? 'bridge-443=open' : 'bridge-443=blocked', + isBridgePort443Available ? 'bridge-443=free' : 'bridge-443=busy', ].join(', '); } diff --git a/test/features/workspace_management/workspace_management_unit_test.dart b/test/features/workspace_management/workspace_management_unit_test.dart index d7dbb36b..0995a319 100644 --- a/test/features/workspace_management/workspace_management_unit_test.dart +++ b/test/features/workspace_management/workspace_management_unit_test.dart @@ -49,6 +49,9 @@ GIT=git version 2.34.1 DNS_OK=1 PORT_443_LISTENERS=0 PORT_443_OPEN=yes +BRIDGE_DNS_OK=1 +BRIDGE_PORT_443_LISTENERS=0 +BRIDGE_PORT_443_OPEN=yes '''); expect(info.os, 'Ubuntu 22.04.4 LTS'); @@ -59,6 +62,9 @@ PORT_443_OPEN=yes expect(info.dnsResolved, isTrue); expect(info.port443Open, isTrue); expect(info.isPort443Available, isTrue); + expect(info.bridgeDnsResolved, isTrue); + expect(info.bridgePort443Open, isTrue); + expect(info.isBridgePort443Available, isTrue); }); test('ansible parser maps human readable output to step events', () { @@ -74,11 +80,36 @@ PORT_443_OPEN=yes }); test('detection command quotes workspace domain', () { - final command = ServerDetector.detectionCommand("a'b.example.com"); + final command = ServerDetector.detectionCommand( + "a'b.example.com", + 'xworkmate-bridge.a\'b.example.com', + ); expect(command, contains("'a'\"'\"'b.example.com'")); + expect(command, contains('xworkmate-bridge.a')); expect(command, contains('getent hosts')); }); + + test('exported yaml redacts sensitive values', () { + final controller = WorkspaceProvisionController(); + addTearDown(controller.dispose); + controller.updateForm( + serverAddress: '203.0.113.10', + workspaceDomain: 'onwalk.net', + sshUsername: 'root', + sshPassword: 'ssh-secret', + deepseekApiKey: 'deepseek-secret', + openclawGatewayToken: 'gateway-secret', + showAdvanced: true, + ); + + final yaml = controller.exportYaml(); + + expect(yaml, contains('server_address: 203.0.113.10')); + expect(yaml, contains('ssh_password_fixture: "example"')); + expect(yaml, contains('deepseek_api_key: "__redacted__"')); + expect(yaml, contains('openclaw_gateway_token: "__redacted__"')); + }); }); group('WorkspaceProvisionController', () { @@ -100,6 +131,9 @@ GIT=git version 2.43.0 DNS_OK=1 PORT_443_LISTENERS=0 PORT_443_OPEN=yes +BRIDGE_DNS_OK=1 +BRIDGE_PORT_443_LISTENERS=0 +BRIDGE_PORT_443_OPEN=yes ''', stderr: '', ), @@ -153,12 +187,18 @@ PORT_443_OPEN=yes dnsAddressCount: 1, port443ListenerCount: 0, port443Open: true, + bridgeDnsAddressCount: 1, + bridgePort443ListenerCount: 0, + bridgePort443Open: true, ); await controller.createWorkspace(); expect(controller.phase, ProvisionPhase.success); - expect(controller.deploymentResult?.url, 'https://workspace.example.com'); + expect( + controller.deploymentResult?.url, + 'https://xworkmate-bridge.workspace.example.com', + ); expect(controller.deploymentResult?.bridgeToken, isNotEmpty); expect(executor.commands.join('\n'), contains('ansible-playbook')); }); @@ -183,6 +223,9 @@ PORT_443_OPEN=yes dnsAddressCount: 1, port443ListenerCount: 0, port443Open: false, + bridgeDnsAddressCount: 1, + bridgePort443ListenerCount: 0, + bridgePort443Open: false, ); expect( @@ -191,7 +234,38 @@ PORT_443_OPEN=yes ); }); - test('precheck blocks unsupported non-Ubuntu systems', () async { + test('precheck blocks when bridge DNS is missing', () async { + final controller = WorkspaceProvisionController(executor: _FakeSshExecutor()); + addTearDown(controller.dispose); + controller.updateForm( + serverAddress: '203.0.113.10', + workspaceDomain: 'onwalk.net', + sshKeyContent: 'key', + ); + controller.serverInfo = const ServerInfo( + os: 'Ubuntu 22.04', + arch: 'x86_64', + sudoAvailable: true, + dockerVersion: 'missing', + systemdVersion: 'systemd 249', + caddyVersion: 'missing', + ansibleVersion: 'ansible [core 2.14]', + gitVersion: 'git version 2.34.1', + dnsAddressCount: 1, + port443ListenerCount: 0, + port443Open: true, + bridgeDnsAddressCount: 0, + bridgePort443ListenerCount: 0, + bridgePort443Open: true, + ); + + expect( + controller.validatePrecheckBlockingIssue(), + contains('xworkmate-bridge.onwalk.net'), + ); + }); + + test('precheck allows debian family systems', () async { final controller = WorkspaceProvisionController(executor: _FakeSshExecutor()); addTearDown(controller.dispose); controller.updateForm( @@ -211,12 +285,45 @@ PORT_443_OPEN=yes dnsAddressCount: 1, port443ListenerCount: 0, port443Open: true, + bridgeDnsAddressCount: 1, + bridgePort443ListenerCount: 0, + bridgePort443Open: true, ); - expect( - controller.validatePrecheckBlockingIssue(), - contains('Ubuntu'), + expect(controller.validatePrecheckBlockingIssue(), isNull); + }); + + test('import yaml restores editable state without leaking redacted values', () { + final controller = WorkspaceProvisionController(); + addTearDown(controller.dispose); + controller.updateForm( + serverAddress: 'old.example.com', + workspaceDomain: 'old.net', + sshUsername: 'root', + sshPassword: 'keep-secret', + showAdvanced: false, ); + + controller.importYaml(''' +server_address: 167.179.110.129 +workspace_domain: onwalk.net +ssh_username: root +auth_method: password +ssh_port: 22 +install_path: /opt/xworkspace/playbooks +show_advanced: true +logs_expanded: false +ssh_password_fixture: "example" +deepseek_api_key: "deepseek-new" +openclaw_gateway_token: "__redacted__" +'''); + + expect(controller.serverAddress, '167.179.110.129'); + expect(controller.workspaceDomain, 'onwalk.net'); + expect(controller.showAdvanced, isTrue); + expect(controller.sshPassword, 'keep-secret'); + expect(controller.deepseekApiKey, 'deepseek-new'); + expect(controller.openclawGatewayToken, isNull); }); }); } From 641e9eb6c12e73103a999928d04a41b54b8f4b66 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 8 Jun 2026 16:35:11 +0800 Subject: [PATCH 05/15] Make workspace advanced configs extensible --- .../workspace_management/playbook_runner.dart | 41 ++- .../workspace_management_form.dart | 256 ++++++++++++++---- .../workspace_provision_controller.dart | 125 +++++---- .../workspace_provision_models.dart | 24 ++ .../workspace_management_unit_test.dart | 39 ++- 5 files changed, 357 insertions(+), 128 deletions(-) diff --git a/lib/features/workspace_management/playbook_runner.dart b/lib/features/workspace_management/playbook_runner.dart index 05287097..b1a19fa3 100644 --- a/lib/features/workspace_management/playbook_runner.dart +++ b/lib/features/workspace_management/playbook_runner.dart @@ -23,10 +23,7 @@ class PlaybookRunner { required String installPath, required bool installMissingPrerequisites, required ServerInfo? serverInfo, - String? deepseekApiKey, - String? nvidiaApiKey, - String? ollamaApiKey, - String? openclawGatewayToken, + List? extraConfigs, required void Function(String stepId, StepStatus status, String? message) onStepUpdate, required void Function(String logLine) onLog, @@ -79,10 +76,7 @@ class PlaybookRunner { workspaceDomain: workspaceDomain, bridgeDomain: bridgeDomain, bridgeToken: bridgeToken, - deepseekApiKey: deepseekApiKey, - nvidiaApiKey: nvidiaApiKey, - ollamaApiKey: ollamaApiKey, - openclawGatewayToken: openclawGatewayToken, + extraConfigs: extraConfigs, ), onLog, ); @@ -183,25 +177,26 @@ class PlaybookRunner { required String workspaceDomain, required String bridgeDomain, required String bridgeToken, - String? deepseekApiKey, - String? nvidiaApiKey, - String? ollamaApiKey, - String? openclawGatewayToken, + List? extraConfigs, }) { final domain = workspaceDomain.trim(); final bridge = bridgeDomain.trim(); final bridgeUrl = 'https://$bridge'; - final extraEnvVars = [ - if ((deepseekApiKey ?? '').trim().isNotEmpty) - 'deepseek_api_key: ${shellQuote(deepseekApiKey!.trim())}', - if ((nvidiaApiKey ?? '').trim().isNotEmpty) - 'nvidia_api_key: ${shellQuote(nvidiaApiKey!.trim())}', - if ((ollamaApiKey ?? '').trim().isNotEmpty) - 'ollama_api_key: ${shellQuote(ollamaApiKey!.trim())}', - if ((openclawGatewayToken ?? '').trim().isNotEmpty) - 'openclaw_gateway_token: ${shellQuote(openclawGatewayToken!.trim())}', - ]; - final extraEnvBlock = extraEnvVars.isEmpty ? '' : '${extraEnvVars.join('\n')}\n'; + final extraEnvVars = []; + for (final config in extraConfigs ?? const []) { + final key = config.key.trim(); + final value = config.value.trim(); + if (key.isEmpty || value.isEmpty) { + continue; + } + extraEnvVars.add('$key: ${shellQuote(value)}'); + final note = config.note.trim(); + if (note.isNotEmpty) { + extraEnvVars.add('# ${note.length > 20 ? note.substring(0, 20) : note}'); + } + } + final extraEnvBlock = + extraEnvVars.isEmpty ? '' : '${extraEnvVars.join('\n')}\n'; return ''' cat > ${shellQuote(inventoryPath)} <<'EOF' [all] diff --git a/lib/features/workspace_management/workspace_management_form.dart b/lib/features/workspace_management/workspace_management_form.dart index 44cceb91..66a1c4c3 100644 --- a/lib/features/workspace_management/workspace_management_form.dart +++ b/lib/features/workspace_management/workspace_management_form.dart @@ -31,10 +31,7 @@ class _WorkspaceManagementFormState extends State { late final TextEditingController _portController; late final TextEditingController _sudoController; late final TextEditingController _installPathController; - late final TextEditingController _deepseekKeyController; - late final TextEditingController _nvidiaKeyController; - late final TextEditingController _ollamaKeyController; - late final TextEditingController _openclawTokenController; + final List<_ExtraRowControllers> _extraRows = <_ExtraRowControllers>[]; @override void initState() { @@ -49,11 +46,15 @@ class _WorkspaceManagementFormState extends State { _portController = TextEditingController(text: c.sshPort.toString()); _sudoController = TextEditingController(text: c.sudoPassword ?? ''); _installPathController = TextEditingController(text: c.installPath); - _deepseekKeyController = TextEditingController(text: c.deepseekApiKey ?? ''); - _nvidiaKeyController = TextEditingController(text: c.nvidiaApiKey ?? ''); - _ollamaKeyController = TextEditingController(text: c.ollamaApiKey ?? ''); - _openclawTokenController = - TextEditingController(text: c.openclawGatewayToken ?? ''); + for (final row in c.extraConfigs) { + _extraRows.add( + _ExtraRowControllers( + keyController: TextEditingController(text: row.key), + valueController: TextEditingController(text: row.value), + noteController: TextEditingController(text: row.note), + ), + ); + } } @override @@ -67,10 +68,9 @@ class _WorkspaceManagementFormState extends State { _portController.dispose(); _sudoController.dispose(); _installPathController.dispose(); - _deepseekKeyController.dispose(); - _nvidiaKeyController.dispose(); - _ollamaKeyController.dispose(); - _openclawTokenController.dispose(); + for (final row in _extraRows) { + row.dispose(); + } super.dispose(); } @@ -87,10 +87,16 @@ class _WorkspaceManagementFormState extends State { installPath: _installPathController.text.trim().isEmpty ? '/opt/xworkspace/playbooks' : _installPathController.text.trim(), - deepseekApiKey: _deepseekKeyController.text, - nvidiaApiKey: _nvidiaKeyController.text, - ollamaApiKey: _ollamaKeyController.text, - openclawGatewayToken: _openclawTokenController.text, + extraConfigs: _extraRows + .map( + (row) => WorkspaceExtraConfig( + key: row.keyController.text.trim(), + value: row.valueController.text, + note: row.noteController.text.trim(), + ), + ) + .where((row) => row.key.trim().isNotEmpty) + .toList(), ); } @@ -134,8 +140,8 @@ class _WorkspaceManagementFormState extends State { padding: const EdgeInsets.only(left: 14, top: 2), child: Text( appText( - '将检测桥接域名:${controller.bridgeDomain}', - 'Bridge domain will be checked: ${controller.bridgeDomain}', + '将按当前输入检测桥接域名:${controller.bridgeDomain}', + 'Bridge domain will be checked from the current input: ${controller.bridgeDomain}', ), style: Theme.of(context).textTheme.bodySmall, ), @@ -248,37 +254,26 @@ class _WorkspaceManagementFormState extends State { label: appText('安装路径', 'Install path'), icon: Icons.storage_outlined, ), - _field( - width: 320, - controller: _deepseekKeyController, + _ExtraConfigEditor( + rows: _extraRows, enabled: !disabled, - label: 'DEEPSEEK_API_KEY', - icon: Icons.key_outlined, - obscureText: true, - ), - _field( - width: 320, - controller: _nvidiaKeyController, - enabled: !disabled, - label: 'NVIDIA_API_KEY', - icon: Icons.key_outlined, - obscureText: true, - ), - _field( - width: 320, - controller: _ollamaKeyController, - enabled: !disabled, - label: 'OLLAMA_API_KEY', - icon: Icons.key_outlined, - obscureText: true, - ), - _field( - width: 320, - controller: _openclawTokenController, - enabled: !disabled, - label: 'OPENCLAW_GATEWAY_TOKEN', - icon: Icons.key_outlined, - obscureText: true, + onAdd: () { + setState(() { + _extraRows.add( + _ExtraRowControllers( + keyController: TextEditingController(), + valueController: TextEditingController(), + noteController: TextEditingController(), + ), + ); + }); + }, + onRemove: (index) { + setState(() { + final row = _extraRows.removeAt(index); + row.dispose(); + }); + }, ), ], ), @@ -359,3 +354,166 @@ class _WorkspaceManagementFormState extends State { ); } } + +class _ExtraConfigEditor extends StatelessWidget { + const _ExtraConfigEditor({ + required this.rows, + required this.enabled, + required this.onAdd, + required this.onRemove, + }); + + final List<_ExtraRowControllers> rows; + final bool enabled; + final VoidCallback onAdd; + final ValueChanged onRemove; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Text( + appText('额外配置', 'Extra configs'), + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const Spacer(), + TextButton.icon( + onPressed: enabled ? onAdd : null, + icon: const Icon(Icons.add), + label: Text(appText('添加行', 'Add row')), + ), + ], + ), + const SizedBox(height: 8), + ...List.generate(rows.length, (index) { + final row = rows[index]; + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _ExtraConfigRow( + index: index, + row: row, + enabled: enabled, + onRemove: () => onRemove(index), + ), + ); + }), + ], + ); + } +} + +class _ExtraConfigRow extends StatelessWidget { + const _ExtraConfigRow({ + required this.index, + required this.row, + required this.enabled, + required this.onRemove, + }); + + final int index; + final _ExtraRowControllers row; + final bool enabled; + final VoidCallback onRemove; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final columns = constraints.maxWidth > 820 ? 3 : 1; + final itemWidth = columns == 3 + ? (constraints.maxWidth - 24) / 3 + : constraints.maxWidth; + return Wrap( + spacing: 12, + runSpacing: 12, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + SizedBox( + width: itemWidth, + child: TextField( + controller: row.keyController, + enabled: enabled, + decoration: InputDecoration( + labelText: appText('KEY', 'KEY'), + prefixIcon: const Icon(Icons.key_outlined, size: 18), + ), + ), + ), + SizedBox( + width: itemWidth, + child: TextField( + controller: row.valueController, + enabled: enabled, + obscureText: row.isSensitiveKey, + decoration: InputDecoration( + labelText: appText('VALUE', 'VALUE'), + prefixIcon: const Icon(Icons.data_object_outlined, size: 18), + ), + ), + ), + SizedBox( + width: itemWidth, + child: TextField( + controller: row.noteController, + enabled: enabled, + maxLength: 20, + decoration: InputDecoration( + labelText: appText('备注(20字内)', 'Note (<=20 chars)'), + prefixIcon: const Icon(Icons.note_outlined, size: 18), + counterText: '', + ), + ), + ), + if (columns == 1) + SizedBox( + width: constraints.maxWidth, + child: Align( + alignment: Alignment.centerRight, + child: IconButton( + onPressed: enabled ? onRemove : null, + icon: const Icon(Icons.delete_outline), + tooltip: appText('删除', 'Delete'), + ), + ), + ) + else + IconButton( + onPressed: enabled ? onRemove : null, + icon: const Icon(Icons.delete_outline), + tooltip: appText('删除', 'Delete'), + ), + ], + ); + }, + ); + } +} + +class _ExtraRowControllers { + _ExtraRowControllers({ + required this.keyController, + required this.valueController, + required this.noteController, + }); + + final TextEditingController keyController; + final TextEditingController valueController; + final TextEditingController noteController; + + bool get isSensitiveKey { + final key = keyController.text.trim().toUpperCase(); + return key.contains('KEY') || key.contains('TOKEN') || key.contains('SECRET'); + } + + void dispose() { + keyController.dispose(); + valueController.dispose(); + noteController.dispose(); + } +} diff --git a/lib/features/workspace_management/workspace_provision_controller.dart b/lib/features/workspace_management/workspace_provision_controller.dart index 340c054b..acdf69aa 100644 --- a/lib/features/workspace_management/workspace_provision_controller.dart +++ b/lib/features/workspace_management/workspace_provision_controller.dart @@ -28,10 +28,12 @@ class WorkspaceProvisionController extends ChangeNotifier { int sshPort = 22; String? sudoPassword; String installPath = '/opt/xworkspace/playbooks'; - String? deepseekApiKey; - String? nvidiaApiKey; - String? ollamaApiKey; - String? openclawGatewayToken; + final List extraConfigs = [ + WorkspaceExtraConfig(key: 'DEEPSEEK_API_KEY', value: '', note: ''), + WorkspaceExtraConfig(key: 'NVIDIA_API_KEY', value: '', note: ''), + WorkspaceExtraConfig(key: 'OLLAMA_API_KEY', value: '', note: ''), + WorkspaceExtraConfig(key: 'OPENCLAW_GATEWAY_TOKEN', value: '', note: ''), + ]; bool showAdvanced = false; bool logsExpanded = false; @@ -146,10 +148,7 @@ class WorkspaceProvisionController extends ChangeNotifier { workspaceDomain: workspaceDomain.trim(), bridgeDomain: bridgeDomain, bridgeToken: bridgeToken, - deepseekApiKey: deepseekApiKey, - nvidiaApiKey: nvidiaApiKey, - ollamaApiKey: ollamaApiKey, - openclawGatewayToken: openclawGatewayToken, + extraConfigs: extraConfigs, installPath: installPath.trim(), installMissingPrerequisites: installMissingPrerequisites, serverInfo: serverInfo, @@ -206,10 +205,7 @@ class WorkspaceProvisionController extends ChangeNotifier { int? sshPort, String? sudoPassword, String? installPath, - String? deepseekApiKey, - String? nvidiaApiKey, - String? ollamaApiKey, - String? openclawGatewayToken, + List? extraConfigs, bool? showAdvanced, bool? logsExpanded, }) { @@ -223,39 +219,41 @@ class WorkspaceProvisionController extends ChangeNotifier { this.sshPort = sshPort ?? this.sshPort; this.sudoPassword = sudoPassword ?? this.sudoPassword; this.installPath = installPath ?? this.installPath; - this.deepseekApiKey = deepseekApiKey ?? this.deepseekApiKey; - this.nvidiaApiKey = nvidiaApiKey ?? this.nvidiaApiKey; - this.ollamaApiKey = ollamaApiKey ?? this.ollamaApiKey; - this.openclawGatewayToken = - openclawGatewayToken ?? this.openclawGatewayToken; + if (extraConfigs != null) { + this.extraConfigs + ..clear() + ..addAll(extraConfigs.map((config) => config.copyWith())); + } this.showAdvanced = showAdvanced ?? this.showAdvanced; this.logsExpanded = logsExpanded ?? this.logsExpanded; notifyListeners(); } String exportYaml() { - final data = { - 'server_address': serverAddress.trim(), - 'workspace_domain': workspaceDomain.trim(), - 'ssh_username': sshUsername.trim(), - 'auth_method': authMethod.name, - 'ssh_port': sshPort, - 'install_path': installPath.trim(), - 'show_advanced': showAdvanced, - 'logs_expanded': logsExpanded, - 'ssh_password': redact(sshPassword), - 'ssh_key_content': redact(sshKeyContent), - 'ssh_key_path': redact(sshKeyPath), - 'sudo_password': redact(sudoPassword), - 'deepseek_api_key': redact(deepseekApiKey), - 'nvidia_api_key': redact(nvidiaApiKey), - 'ollama_api_key': redact(ollamaApiKey), - 'openclaw_gateway_token': redact(openclawGatewayToken), - }; final buffer = StringBuffer(); - for (final entry in data.entries) { + final entries = >[ + MapEntry('server_address', serverAddress.trim()), + MapEntry('workspace_domain', workspaceDomain.trim()), + MapEntry('ssh_username', sshUsername.trim()), + MapEntry('auth_method', authMethod.name), + MapEntry('ssh_port', sshPort), + MapEntry('install_path', installPath.trim()), + MapEntry('show_advanced', showAdvanced), + MapEntry('logs_expanded', logsExpanded), + MapEntry('ssh_password', redact(sshPassword)), + MapEntry('ssh_key_content', redact(sshKeyContent)), + MapEntry('ssh_key_path', redact(sshKeyPath)), + MapEntry('sudo_password', redact(sudoPassword)), + ]; + for (final entry in entries) { buffer.writeln('${entry.key}: ${yamlScalar(entry.value)}'); } + buffer.writeln('extra_configs:'); + for (final config in extraConfigs) { + buffer.writeln(' - key: ${yamlScalar(config.key.trim())}'); + buffer.writeln(' value: ${yamlScalar(redact(config.value))}'); + buffer.writeln(' note: ${yamlScalar(sanitizeNote(config.note))}'); + } return buffer.toString().trimRight(); } @@ -279,13 +277,7 @@ class WorkspaceProvisionController extends ChangeNotifier { sshPort: intValue(map['ssh_port'], sshPort), sudoPassword: secretValue(map['sudo_password'], sudoPassword), installPath: stringValue(map['install_path']), - deepseekApiKey: secretValue(map['deepseek_api_key'], deepseekApiKey), - nvidiaApiKey: secretValue(map['nvidia_api_key'], nvidiaApiKey), - ollamaApiKey: secretValue(map['ollama_api_key'], ollamaApiKey), - openclawGatewayToken: secretValue( - map['openclaw_gateway_token'], - openclawGatewayToken, - ), + extraConfigs: parseExtraConfigs(map['extra_configs'], extraConfigs), showAdvanced: boolValue(map['show_advanced'], showAdvanced), logsExpanded: boolValue(map['logs_expanded'], logsExpanded), ); @@ -386,10 +378,6 @@ class WorkspaceProvisionController extends ChangeNotifier { sshPassword = null; sshKeyContent = null; sudoPassword = null; - deepseekApiKey = null; - nvidiaApiKey = null; - ollamaApiKey = null; - openclawGatewayToken = null; super.dispose(); } @@ -398,7 +386,7 @@ class WorkspaceProvisionController extends ChangeNotifier { if (domain.isEmpty) { return ''; } - if (domain.startsWith('xworkmate-bridge.')) { + if (domain.contains('bridge.') || domain.startsWith('bridge.')) { return domain; } return 'xworkmate-bridge.$domain'; @@ -461,6 +449,47 @@ class WorkspaceProvisionController extends ChangeNotifier { final text = value?.toString().trim().toLowerCase() ?? ''; return text == 'password' ? AuthMethod.password : AuthMethod.sshKey; } + + static String sanitizeNote(String note) { + final trimmed = note.trim(); + return trimmed.length <= 20 ? trimmed : trimmed.substring(0, 20); + } + + static List parseExtraConfigs( + Object? value, + List current, + ) { + final existing = { + for (final config in current) config.key.trim(): config, + }; + final parsed = []; + if (value is YamlList) { + for (final item in value) { + if (item is! YamlMap) { + continue; + } + final key = item['key']?.toString().trim() ?? ''; + if (key.isEmpty) { + continue; + } + final rawValue = item['value']?.toString().trim() ?? ''; + final note = sanitizeNote(item['note']?.toString() ?? ''); + parsed.add( + WorkspaceExtraConfig( + key: key, + value: rawValue == redactedValue + ? (existing[key]?.value ?? '') + : rawValue, + note: note, + ), + ); + } + } + if (parsed.isNotEmpty) { + return parsed; + } + return current.map((config) => config.copyWith()).toList(); + } } class WorkspaceProvisionPrecheckException implements Exception { diff --git a/lib/features/workspace_management/workspace_provision_models.dart b/lib/features/workspace_management/workspace_provision_models.dart index 2e7ee465..13270ed3 100644 --- a/lib/features/workspace_management/workspace_provision_models.dart +++ b/lib/features/workspace_management/workspace_provision_models.dart @@ -131,6 +131,30 @@ class SshConfig { String get targetLabel => '$username@$host:$port'; } +class WorkspaceExtraConfig { + WorkspaceExtraConfig({ + required this.key, + required this.value, + this.note = '', + }); + + String key; + String value; + String note; + + WorkspaceExtraConfig copyWith({ + String? key, + String? value, + String? note, + }) { + return WorkspaceExtraConfig( + key: key ?? this.key, + value: value ?? this.value, + note: note ?? this.note, + ); + } +} + class SshResult { const SshResult({ required this.exitCode, diff --git a/test/features/workspace_management/workspace_management_unit_test.dart b/test/features/workspace_management/workspace_management_unit_test.dart index 0995a319..ad6a395e 100644 --- a/test/features/workspace_management/workspace_management_unit_test.dart +++ b/test/features/workspace_management/workspace_management_unit_test.dart @@ -90,6 +90,13 @@ BRIDGE_PORT_443_OPEN=yes expect(command, contains('getent hosts')); }); + test('bridge domain uses user input when already a bridge host', () { + expect( + WorkspaceProvisionController.deriveBridgeDomain('acp-bridge.onwalk.net'), + 'acp-bridge.onwalk.net', + ); + }); + test('exported yaml redacts sensitive values', () { final controller = WorkspaceProvisionController(); addTearDown(controller.dispose); @@ -98,17 +105,28 @@ BRIDGE_PORT_443_OPEN=yes workspaceDomain: 'onwalk.net', sshUsername: 'root', sshPassword: 'ssh-secret', - deepseekApiKey: 'deepseek-secret', - openclawGatewayToken: 'gateway-secret', showAdvanced: true, + extraConfigs: [ + WorkspaceExtraConfig( + key: 'DEEPSEEK_API_KEY', + value: 'deepseek-secret', + note: '深度搜索', + ), + WorkspaceExtraConfig( + key: 'OPENCLAW_GATEWAY_TOKEN', + value: 'gateway-secret', + note: 'OpenClaw', + ), + ], ); final yaml = controller.exportYaml(); expect(yaml, contains('server_address: 203.0.113.10')); expect(yaml, contains('ssh_password_fixture: "example"')); - expect(yaml, contains('deepseek_api_key: "__redacted__"')); - expect(yaml, contains('openclaw_gateway_token: "__redacted__"')); + expect(yaml, contains('extra_configs:')); + expect(yaml, contains('key: DEEPSEEK_API_KEY')); + expect(yaml, contains('value: "__redacted__"')); }); }); @@ -314,16 +332,21 @@ install_path: /opt/xworkspace/playbooks show_advanced: true logs_expanded: false ssh_password_fixture: "example" -deepseek_api_key: "deepseek-new" -openclaw_gateway_token: "__redacted__" +extra_configs: + - key: DEEPSEEK_API_KEY + value: "deepseek-new" + note: "深度搜索" + - key: OPENCLAW_GATEWAY_TOKEN + value: "__redacted__" + note: "OpenClaw" '''); expect(controller.serverAddress, '167.179.110.129'); expect(controller.workspaceDomain, 'onwalk.net'); expect(controller.showAdvanced, isTrue); expect(controller.sshPassword, 'keep-secret'); - expect(controller.deepseekApiKey, 'deepseek-new'); - expect(controller.openclawGatewayToken, isNull); + expect(controller.extraConfigs.first.value, 'deepseek-new'); + expect(controller.extraConfigs.last.value, ''); }); }); } From b06a27a3cf63dd88ccaa12502740099956b9e262 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 8 Jun 2026 16:53:50 +0800 Subject: [PATCH 06/15] Clarify bridge DNS precheck message --- .../workspace_management/workspace_provision_controller.dart | 4 ++-- .../workspace_management/workspace_management_unit_test.dart | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/features/workspace_management/workspace_provision_controller.dart b/lib/features/workspace_management/workspace_provision_controller.dart index acdf69aa..e5ab5e2e 100644 --- a/lib/features/workspace_management/workspace_provision_controller.dart +++ b/lib/features/workspace_management/workspace_provision_controller.dart @@ -354,8 +354,8 @@ class WorkspaceProvisionController extends ChangeNotifier { } if (!info.bridgeDnsResolved) { return appText( - '部署前需要先把 $bridgeDomain 做好 DNS 解析。', - 'Configure DNS for $bridgeDomain before deploying.', + '目标服务器当前无法解析 $bridgeDomain。请先在 DNS 服务商添加这条主机名的 A 记录,并确认在 VPS 上执行 dig/getent 能返回地址。', + 'The target server cannot resolve $bridgeDomain. Add an A record for this host at your DNS provider, then confirm dig/getent returns an address on the VPS.', ); } if (!info.bridgePort443Open) { diff --git a/test/features/workspace_management/workspace_management_unit_test.dart b/test/features/workspace_management/workspace_management_unit_test.dart index ad6a395e..c6dbd38c 100644 --- a/test/features/workspace_management/workspace_management_unit_test.dart +++ b/test/features/workspace_management/workspace_management_unit_test.dart @@ -279,7 +279,7 @@ BRIDGE_PORT_443_OPEN=yes expect( controller.validatePrecheckBlockingIssue(), - contains('xworkmate-bridge.onwalk.net'), + contains('A 记录'), ); }); From 03296b4e2115216ca332e8f31f3c38db84df0d98 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 8 Jun 2026 17:43:40 +0800 Subject: [PATCH 07/15] Relax workspace prechecks and add post-deploy validation --- .../workspace_management/server_detector.dart | 31 ++++- .../workspace_management_form.dart | 61 ++++++++++ .../workspace_provision_controller.dart | 111 ++++++++++++++++-- .../workspace_provision_models.dart | 14 +++ .../workspace_management_unit_test.dart | 106 ++++++++++++++++- 5 files changed, 303 insertions(+), 20 deletions(-) diff --git a/lib/features/workspace_management/server_detector.dart b/lib/features/workspace_management/server_detector.dart index 3adc360d..39d9718a 100644 --- a/lib/features/workspace_management/server_detector.dart +++ b/lib/features/workspace_management/server_detector.dart @@ -39,15 +39,24 @@ echo "CADDY=\$(caddy version 2>/dev/null || echo missing)" echo "ANSIBLE=\$(ansible --version 2>/dev/null | head -1 || echo missing)" echo "GIT=\$(git --version 2>/dev/null || echo missing)" echo "DNS_OK=\$(getent hosts $domain 2>/dev/null | wc -l | tr -d ' ')" +echo "PORT_80_LISTENERS=\$(ss -ltn '( sport = :80 )' 2>/dev/null | tail -n +2 | wc -l | tr -d ' ')" echo "PORT_443_LISTENERS=\$(ss -ltn '( sport = :443 )' 2>/dev/null | tail -n +2 | wc -l | tr -d ' ')" echo "BRIDGE_DNS_OK=\$(getent hosts $bridge 2>/dev/null | wc -l | tr -d ' ')" +echo "BRIDGE_PORT_80_LISTENERS=\$(ss -ltn '( sport = :80 )' 2>/dev/null | tail -n +2 | wc -l | tr -d ' ')" echo "BRIDGE_PORT_443_LISTENERS=\$(ss -ltn '( sport = :443 )' 2>/dev/null | tail -n +2 | wc -l | tr -d ' ')" +PORT_80_OPEN=yes PORT_443_OPEN=yes if command -v ufw >/dev/null 2>&1; then UFW_STATUS="\$(ufw status 2>/dev/null || sudo -n ufw status 2>/dev/null || echo unavailable)" if printf '%s' "\$UFW_STATUS" | grep -qi 'Status: inactive'; then + PORT_80_OPEN=yes PORT_443_OPEN=yes - elif printf '%s' "\$UFW_STATUS" | grep -Eqi '(^|[[:space:]])(443(/tcp)?|https)[[:space:]]+ALLOW'; then + elif printf '%s' "\$UFW_STATUS" | grep -Eqi '(^|[[:space:]])(80(/tcp)?|http)[[:space:]]+ALLOW'; then + PORT_80_OPEN=yes + else + PORT_80_OPEN=no + fi + if printf '%s' "\$UFW_STATUS" | grep -Eqi '(^|[[:space:]])(443(/tcp)?|https)[[:space:]]+ALLOW'; then PORT_443_OPEN=yes else PORT_443_OPEN=no @@ -55,6 +64,14 @@ if command -v ufw >/dev/null 2>&1; then elif command -v firewall-cmd >/dev/null 2>&1; then FIREWALL_STATE="\$(firewall-cmd --state 2>/dev/null || sudo -n firewall-cmd --state 2>/dev/null || echo not-running)" if [ "\$FIREWALL_STATE" = "running" ]; then + if firewall-cmd --quiet --query-service=http 2>/dev/null || + sudo -n firewall-cmd --quiet --query-service=http 2>/dev/null || + firewall-cmd --quiet --query-port=80/tcp 2>/dev/null || + sudo -n firewall-cmd --quiet --query-port=80/tcp 2>/dev/null; then + PORT_80_OPEN=yes + else + PORT_80_OPEN=no + fi if firewall-cmd --quiet --query-service=https 2>/dev/null || sudo -n firewall-cmd --quiet --query-service=https 2>/dev/null || firewall-cmd --quiet --query-port=443/tcp 2>/dev/null || @@ -64,10 +81,13 @@ if command -v ufw >/dev/null 2>&1; then PORT_443_OPEN=no fi else + PORT_80_OPEN=yes PORT_443_OPEN=yes fi fi +echo "PORT_80_OPEN=\$PORT_80_OPEN" echo "PORT_443_OPEN=\$PORT_443_OPEN" +echo "BRIDGE_PORT_80_OPEN=\$PORT_80_OPEN" echo "BRIDGE_PORT_443_OPEN=\$PORT_443_OPEN" '''; } @@ -91,11 +111,20 @@ echo "BRIDGE_PORT_443_OPEN=\$PORT_443_OPEN" ansibleVersion: values['ANSIBLE'] ?? 'missing', gitVersion: values['GIT'] ?? 'missing', dnsAddressCount: int.tryParse(values['DNS_OK'] ?? '') ?? 0, + port80ListenerCount: + int.tryParse(values['PORT_80_LISTENERS'] ?? '') ?? 0, + port80Open: (values['PORT_80_OPEN'] ?? '').toLowerCase() != 'no', port443ListenerCount: int.tryParse(values['PORT_443_LISTENERS'] ?? '') ?? 0, port443Open: (values['PORT_443_OPEN'] ?? '').toLowerCase() != 'no', bridgeDnsAddressCount: int.tryParse(values['BRIDGE_DNS_OK'] ?? '') ?? 0, + bridgePort80ListenerCount: + int.tryParse(values['BRIDGE_PORT_80_LISTENERS'] ?? '') ?? 0, + bridgePort80Open: + (values['BRIDGE_PORT_80_OPEN'] ?? values['PORT_80_OPEN'] ?? '') + .toLowerCase() != + 'no', bridgePort443ListenerCount: int.tryParse(values['BRIDGE_PORT_443_LISTENERS'] ?? '') ?? 0, bridgePort443Open: diff --git a/lib/features/workspace_management/workspace_management_form.dart b/lib/features/workspace_management/workspace_management_form.dart index 66a1c4c3..7f87bb58 100644 --- a/lib/features/workspace_management/workspace_management_form.dart +++ b/lib/features/workspace_management/workspace_management_form.dart @@ -32,11 +32,13 @@ class _WorkspaceManagementFormState extends State { late final TextEditingController _sudoController; late final TextEditingController _installPathController; final List<_ExtraRowControllers> _extraRows = <_ExtraRowControllers>[]; + bool _syncingFromController = false; @override void initState() { super.initState(); final c = widget.controller; + widget.controller.addListener(_handleControllerUpdate); _serverController = TextEditingController(text: c.serverAddress); _domainController = TextEditingController(text: c.workspaceDomain); _userController = TextEditingController(text: c.sshUsername); @@ -57,8 +59,67 @@ class _WorkspaceManagementFormState extends State { } } + void _handleControllerUpdate() { + if (!mounted || _syncingFromController) { + return; + } + _syncingFromController = true; + try { + _syncText(_serverController, widget.controller.serverAddress); + _syncText(_domainController, widget.controller.workspaceDomain); + _syncText(_userController, widget.controller.sshUsername); + _syncText(_passwordController, widget.controller.sshPassword ?? ''); + _syncText(_keyController, widget.controller.sshKeyContent ?? ''); + _syncText(_keyPathController, widget.controller.sshKeyPath ?? ''); + _syncText(_portController, widget.controller.sshPort.toString()); + _syncText(_sudoController, widget.controller.sudoPassword ?? ''); + _syncText(_installPathController, widget.controller.installPath); + _syncExtraRows(widget.controller.extraConfigs); + } finally { + _syncingFromController = false; + } + } + + void _syncText(TextEditingController controller, String value) { + if (controller.text != value) { + controller.value = controller.value.copyWith( + text: value, + selection: TextSelection.collapsed(offset: value.length), + composing: TextRange.empty, + ); + } + } + + void _syncExtraRows(List configs) { + if (_extraRows.length != configs.length) { + for (final row in _extraRows) { + row.dispose(); + } + _extraRows + ..clear() + ..addAll( + configs.map( + (row) => _ExtraRowControllers( + keyController: TextEditingController(text: row.key), + valueController: TextEditingController(text: row.value), + noteController: TextEditingController(text: row.note), + ), + ), + ); + return; + } + for (var i = 0; i < configs.length; i++) { + final source = configs[i]; + final row = _extraRows[i]; + _syncText(row.keyController, source.key); + _syncText(row.valueController, source.value); + _syncText(row.noteController, source.note); + } + } + @override void dispose() { + widget.controller.removeListener(_handleControllerUpdate); _serverController.dispose(); _domainController.dispose(); _userController.dispose(); diff --git a/lib/features/workspace_management/workspace_provision_controller.dart b/lib/features/workspace_management/workspace_provision_controller.dart index e5ab5e2e..b2a11728 100644 --- a/lib/features/workspace_management/workspace_provision_controller.dart +++ b/lib/features/workspace_management/workspace_provision_controller.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/foundation.dart'; import 'package:yaml/yaml.dart'; @@ -11,12 +13,15 @@ class WorkspaceProvisionController extends ChangeNotifier { WorkspaceProvisionController({ WorkspaceSshExecutor? executor, String initialWorkspaceDomain = '', + Future Function(String host)? externalPortProbe, }) : executor = executor ?? const DartSshExecutor(), - workspaceDomain = initialWorkspaceDomain { + workspaceDomain = initialWorkspaceDomain, + externalPortProbe = externalPortProbe ?? _probeExternalPorts { steps = defaultProvisionSteps(); } final WorkspaceSshExecutor executor; + final Future Function(String host) externalPortProbe; String serverAddress = ''; String workspaceDomain = ''; @@ -155,6 +160,7 @@ class WorkspaceProvisionController extends ChangeNotifier { onStepUpdate: _setStep, onLog: _appendLog, ); + await _verifyDeploymentReadiness(); for (final step in steps) { if (step.status == StepStatus.pending || step.status == StepStatus.running) { _setStep(step.id, StepStatus.success, null); @@ -358,21 +364,100 @@ class WorkspaceProvisionController extends ChangeNotifier { 'The target server cannot resolve $bridgeDomain. Add an A record for this host at your DNS provider, then confirm dig/getent returns an address on the VPS.', ); } - if (!info.bridgePort443Open) { - return appText( - '$bridgeDomain 的 443 端口未开放,请先放通 HTTPS 访问。', - 'Port 443 is not open for $bridgeDomain. Allow HTTPS traffic first.', - ); - } - if (!info.isBridgePort443Available) { - return appText( - '$bridgeDomain 的 443 端口已被占用,请先释放。', - 'Port 443 is already in use for $bridgeDomain.', - ); - } return null; } + Future _verifyDeploymentReadiness() async { + final result = await executor.execute( + sshConfig(), + _coreServicesCheckCommand(), + ); + for (final line in result.combinedOutput.split(RegExp(r'\r?\n'))) { + if (line.trim().isNotEmpty) { + _appendLog(line); + } + } + if (!result.success) { + throw PlaybookRunException( + appText( + '部署完成后核心服务校验失败,请查看日志。', + 'Core service validation failed after deployment. Check logs.', + ), + ); + } + + final serviceStates = {}; + for (final raw in result.combinedOutput.split(RegExp(r'\r?\n'))) { + final match = RegExp(r'^SERVICE_(.+?)=(.+)$').firstMatch(raw.trim()); + if (match != null) { + serviceStates[match.group(1)!] = match.group(2)!; + } + } + final unhealthy = serviceStates.entries + .where((entry) => entry.value.trim().toLowerCase() != 'active') + .map((entry) => entry.key) + .toList(); + if (unhealthy.isNotEmpty) { + throw PlaybookRunException( + appText( + '部署完成后核心服务未处于 active:${unhealthy.join(', ')}。', + 'Core services are not active after deployment: ${unhealthy.join(', ')}.', + ), + ); + } + + await externalPortProbe(bridgeDomain.trim()); + } + + String _coreServicesCheckCommand() { + final services = [ + 'caddy', + 'xworkmate-bridge', + 'openclaw-gateway', + 'hermes-gateway', + ]; + final buffer = StringBuffer(); + buffer.writeln('set +e'); + for (final service in services) { + final unit = shellQuote('$service.service'); + final envKey = service.toUpperCase().replaceAll('-', '_'); + buffer.writeln('if systemctl list-unit-files $unit >/dev/null 2>&1 || systemctl status $unit >/dev/null 2>&1; then'); + buffer.writeln(' STATE=\$(systemctl is-active $unit 2>/dev/null || echo inactive)'); + buffer.writeln(' echo SERVICE_$envKey=\$STATE'); + buffer.writeln('fi'); + } + buffer.writeln('exit 0'); + return buffer.toString(); + } + + static Future _probeExternalPorts(String host) async { + final bridgeHost = host.trim(); + if (bridgeHost.isEmpty) { + return; + } + final probeFailures = []; + for (final port in [80, 443]) { + try { + final socket = await Socket.connect( + bridgeHost, + port, + timeout: const Duration(seconds: 3), + ); + socket.destroy(); + } catch (_) { + probeFailures.add('$port'); + } + } + if (probeFailures.isNotEmpty) { + throw PlaybookRunException( + appText( + '部署已完成,但外部探测仍然不通:$bridgeHost 的 ${probeFailures.join(' / ')} 端口未真正放行。', + 'Deployment finished, but external probes still fail: $bridgeHost ports ${probeFailures.join(' / ')} are not truly open.', + ), + ); + } + } + @override void dispose() { sshPassword = null; diff --git a/lib/features/workspace_management/workspace_provision_models.dart b/lib/features/workspace_management/workspace_provision_models.dart index 13270ed3..b4d0936d 100644 --- a/lib/features/workspace_management/workspace_provision_models.dart +++ b/lib/features/workspace_management/workspace_provision_models.dart @@ -56,9 +56,13 @@ class ServerInfo { required this.ansibleVersion, required this.gitVersion, required this.dnsAddressCount, + required this.port80ListenerCount, + required this.port80Open, required this.port443ListenerCount, required this.port443Open, required this.bridgeDnsAddressCount, + required this.bridgePort80ListenerCount, + required this.bridgePort80Open, required this.bridgePort443ListenerCount, required this.bridgePort443Open, }); @@ -72,9 +76,13 @@ class ServerInfo { final String ansibleVersion; final String gitVersion; final int dnsAddressCount; + final int port80ListenerCount; + final bool port80Open; final int port443ListenerCount; final bool port443Open; final int bridgeDnsAddressCount; + final int bridgePort80ListenerCount; + final bool bridgePort80Open; final int bridgePort443ListenerCount; final bool bridgePort443Open; @@ -82,8 +90,10 @@ class ServerInfo { bool get ansibleMissing => _isMissing(ansibleVersion); bool get hasMissingPrerequisites => gitMissing || ansibleMissing; bool get dnsResolved => dnsAddressCount > 0; + bool get isPort80Available => port80ListenerCount == 0; bool get isPort443Available => port443ListenerCount == 0; bool get bridgeDnsResolved => bridgeDnsAddressCount > 0; + bool get isBridgePort80Available => bridgePort80ListenerCount == 0; bool get isBridgePort443Available => bridgePort443ListenerCount == 0; String get displaySummary { @@ -93,9 +103,13 @@ class ServerInfo { if (arch.trim().isNotEmpty) arch.trim(), sudo, dnsResolved ? 'dns=ok' : 'dns=missing', + port80Open ? '80=open' : '80=blocked', + isPort80Available ? '80=free' : '80=busy', port443Open ? '443=open' : '443=blocked', isPort443Available ? '443=free' : '443=busy', bridgeDnsResolved ? 'bridge-dns=ok' : 'bridge-dns=missing', + bridgePort80Open ? 'bridge-80=open' : 'bridge-80=blocked', + isBridgePort80Available ? 'bridge-80=free' : 'bridge-80=busy', bridgePort443Open ? 'bridge-443=open' : 'bridge-443=blocked', isBridgePort443Available ? 'bridge-443=free' : 'bridge-443=busy', ].join(', '); diff --git a/test/features/workspace_management/workspace_management_unit_test.dart b/test/features/workspace_management/workspace_management_unit_test.dart index c6dbd38c..78b389d7 100644 --- a/test/features/workspace_management/workspace_management_unit_test.dart +++ b/test/features/workspace_management/workspace_management_unit_test.dart @@ -47,10 +47,14 @@ CADDY=missing ANSIBLE=missing GIT=git version 2.34.1 DNS_OK=1 +PORT_80_LISTENERS=0 PORT_443_LISTENERS=0 +PORT_80_OPEN=yes PORT_443_OPEN=yes BRIDGE_DNS_OK=1 +BRIDGE_PORT_80_LISTENERS=0 BRIDGE_PORT_443_LISTENERS=0 +BRIDGE_PORT_80_OPEN=yes BRIDGE_PORT_443_OPEN=yes '''); @@ -60,9 +64,13 @@ BRIDGE_PORT_443_OPEN=yes expect(info.ansibleMissing, isTrue); expect(info.gitMissing, isFalse); expect(info.dnsResolved, isTrue); + expect(info.port80Open, isTrue); + expect(info.isPort80Available, isTrue); expect(info.port443Open, isTrue); expect(info.isPort443Available, isTrue); expect(info.bridgeDnsResolved, isTrue); + expect(info.bridgePort80Open, isTrue); + expect(info.isBridgePort80Available, isTrue); expect(info.bridgePort443Open, isTrue); expect(info.isBridgePort443Available, isTrue); }); @@ -147,10 +155,14 @@ CADDY=missing ANSIBLE=ansible [core 2.16] GIT=git version 2.43.0 DNS_OK=1 +PORT_80_LISTENERS=0 PORT_443_LISTENERS=0 +PORT_80_OPEN=yes PORT_443_OPEN=yes BRIDGE_DNS_OK=1 +BRIDGE_PORT_80_LISTENERS=0 BRIDGE_PORT_443_LISTENERS=0 +BRIDGE_PORT_80_OPEN=yes BRIDGE_PORT_443_OPEN=yes ''', stderr: '', @@ -180,13 +192,26 @@ BRIDGE_PORT_443_OPEN=yes commandResults: [ const SshResult(exitCode: 0, stdout: 'pulled', stderr: ''), const SshResult(exitCode: 0, stdout: 'wrote', stderr: ''), + const SshResult( + exitCode: 0, + stdout: ''' +SERVICE_CADDY=active +SERVICE_XWORKMATE_BRIDGE=active +SERVICE_OPENCLAW_GATEWAY=active +SERVICE_HERMES_GATEWAY=active +''', + stderr: '', + ), ], streamingChunks: [ 'TASK [Install desktop packages]\nok: [localhost]\n', 'TASK [Configure caddy TLS]\nchanged: [localhost]\n', ], ); - final controller = WorkspaceProvisionController(executor: executor); + final controller = WorkspaceProvisionController( + executor: executor, + externalPortProbe: (_) async {}, + ); addTearDown(controller.dispose); controller.updateForm( serverAddress: '203.0.113.10', @@ -203,9 +228,13 @@ BRIDGE_PORT_443_OPEN=yes ansibleVersion: 'ansible [core 2.14]', gitVersion: 'git version 2.34.1', dnsAddressCount: 1, + port80ListenerCount: 0, + port80Open: true, port443ListenerCount: 0, port443Open: true, bridgeDnsAddressCount: 1, + bridgePort80ListenerCount: 0, + bridgePort80Open: true, bridgePort443ListenerCount: 0, bridgePort443Open: true, ); @@ -221,7 +250,7 @@ BRIDGE_PORT_443_OPEN=yes expect(executor.commands.join('\n'), contains('ansible-playbook')); }); - test('precheck blocks when 443 is not open', () async { + test('precheck does not block when 443 is not open', () async { final controller = WorkspaceProvisionController(executor: _FakeSshExecutor()); addTearDown(controller.dispose); controller.updateForm( @@ -239,17 +268,18 @@ BRIDGE_PORT_443_OPEN=yes ansibleVersion: 'ansible [core 2.14]', gitVersion: 'git version 2.34.1', dnsAddressCount: 1, + port80ListenerCount: 0, + port80Open: true, port443ListenerCount: 0, port443Open: false, bridgeDnsAddressCount: 1, + bridgePort80ListenerCount: 0, + bridgePort80Open: true, bridgePort443ListenerCount: 0, bridgePort443Open: false, ); - expect( - controller.validatePrecheckBlockingIssue(), - contains('443'), - ); + expect(controller.validatePrecheckBlockingIssue(), isNull); }); test('precheck blocks when bridge DNS is missing', () async { @@ -270,9 +300,13 @@ BRIDGE_PORT_443_OPEN=yes ansibleVersion: 'ansible [core 2.14]', gitVersion: 'git version 2.34.1', dnsAddressCount: 1, + port80ListenerCount: 0, + port80Open: true, port443ListenerCount: 0, port443Open: true, bridgeDnsAddressCount: 0, + bridgePort80ListenerCount: 0, + bridgePort80Open: true, bridgePort443ListenerCount: 0, bridgePort443Open: true, ); @@ -301,9 +335,13 @@ BRIDGE_PORT_443_OPEN=yes ansibleVersion: 'ansible [core 2.14]', gitVersion: 'git version 2.34.1', dnsAddressCount: 1, + port80ListenerCount: 0, + port80Open: true, port443ListenerCount: 0, port443Open: true, bridgeDnsAddressCount: 1, + bridgePort80ListenerCount: 0, + bridgePort80Open: true, bridgePort443ListenerCount: 0, bridgePort443Open: true, ); @@ -348,6 +386,62 @@ extra_configs: expect(controller.extraConfigs.first.value, 'deepseek-new'); expect(controller.extraConfigs.last.value, ''); }); + + test('post deploy verification fails when external probe does not connect', () async { + final controller = WorkspaceProvisionController( + executor: _FakeSshExecutor( + commandResults: [ + const SshResult(exitCode: 0, stdout: 'pulled', stderr: ''), + const SshResult(exitCode: 0, stdout: 'wrote', stderr: ''), + const SshResult( + exitCode: 0, + stdout: ''' +SERVICE_CADDY=active +SERVICE_XWORKMATE_BRIDGE=active +''', + stderr: '', + ), + ], + streamingChunks: [ + 'TASK [Configure caddy TLS]\nchanged: [localhost]\n', + ], + ), + externalPortProbe: (host) async { + throw PlaybookRunException('probe failed for $host'); + }, + ); + addTearDown(controller.dispose); + controller.updateForm( + serverAddress: '203.0.113.10', + workspaceDomain: 'workspace.example.com', + sshKeyContent: 'key', + ); + controller.serverInfo = const ServerInfo( + os: 'Ubuntu 22.04', + arch: 'x86_64', + sudoAvailable: true, + dockerVersion: 'missing', + systemdVersion: 'systemd 249', + caddyVersion: 'missing', + ansibleVersion: 'ansible [core 2.14]', + gitVersion: 'git version 2.34.1', + dnsAddressCount: 1, + port80ListenerCount: 0, + port80Open: true, + port443ListenerCount: 0, + port443Open: true, + bridgeDnsAddressCount: 1, + bridgePort80ListenerCount: 0, + bridgePort80Open: true, + bridgePort443ListenerCount: 0, + bridgePort443Open: true, + ); + + await controller.createWorkspace(); + + expect(controller.phase, ProvisionPhase.failed); + expect(controller.errorMessage, contains('probe failed')); + }); }); } From 6ff7d892b51ef46c50c0563c187fbd53bd03cff2 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 8 Jun 2026 18:13:52 +0800 Subject: [PATCH 08/15] Improve workspace status summary wording --- .../workspace_provision_models.dart | 53 +++++++++++++------ .../workspace_management_unit_test.dart | 3 ++ 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/lib/features/workspace_management/workspace_provision_models.dart b/lib/features/workspace_management/workspace_provision_models.dart index b4d0936d..beb7f804 100644 --- a/lib/features/workspace_management/workspace_provision_models.dart +++ b/lib/features/workspace_management/workspace_provision_models.dart @@ -97,22 +97,42 @@ class ServerInfo { bool get isBridgePort443Available => bridgePort443ListenerCount == 0; String get displaySummary { - final sudo = sudoAvailable ? 'sudo=yes' : 'sudo=no'; - return [ + final systemParts = [ if (os.trim().isNotEmpty) os.trim(), if (arch.trim().isNotEmpty) arch.trim(), - sudo, - dnsResolved ? 'dns=ok' : 'dns=missing', - port80Open ? '80=open' : '80=blocked', - isPort80Available ? '80=free' : '80=busy', - port443Open ? '443=open' : '443=blocked', - isPort443Available ? '443=free' : '443=busy', - bridgeDnsResolved ? 'bridge-dns=ok' : 'bridge-dns=missing', - bridgePort80Open ? 'bridge-80=open' : 'bridge-80=blocked', - isBridgePort80Available ? 'bridge-80=free' : 'bridge-80=busy', - bridgePort443Open ? 'bridge-443=open' : 'bridge-443=blocked', - isBridgePort443Available ? 'bridge-443=free' : 'bridge-443=busy', - ].join(', '); + sudoAvailable ? 'sudo 可用' : 'sudo 不可用', + ]; + final bridgeParts = [ + dnsResolved ? '主域名 DNS 已解析' : '主域名 DNS 未解析', + bridgeDnsResolved ? '桥接域名 DNS 已解析' : '桥接域名 DNS 未解析', + ]; + final portParts = [ + port80Open + ? '80 端口策略已放行' + : '80 端口策略未放行', + isPort80Available ? '80 端口当前空闲' : '80 端口当前被占用', + port443Open + ? '443 端口策略已放行' + : '443 端口策略未放行', + isPort443Available ? '443 端口当前空闲' : '443 端口当前被占用', + bridgePort80Open + ? '桥接 80 端口策略已放行' + : '桥接 80 端口策略未放行', + isBridgePort80Available + ? '桥接 80 端口当前空闲' + : '桥接 80 端口当前被占用', + bridgePort443Open + ? '桥接 443 端口策略已放行' + : '桥接 443 端口策略未放行', + isBridgePort443Available + ? '桥接 443 端口当前空闲' + : '桥接 443 端口当前被占用', + ]; + return [ + if (systemParts.isNotEmpty) systemParts.join(' · '), + if (bridgeParts.isNotEmpty) bridgeParts.join(' · '), + if (portParts.isNotEmpty) portParts.join(' · '), + ].join('\n'); } static bool _isMissing(String value) => @@ -253,7 +273,10 @@ List defaultProvisionSteps() { ), ProvisionStep( id: 'deploy_webrtc', - title: appText('部署 WebRTC 远端桌面', 'Deploy WebRTC remote desktop'), + title: appText( + '部署 AI 智能体工作空间', + 'Deploy AI Agentic Workspace environment', + ), phaseGroup: 'console', ), ProvisionStep( diff --git a/test/features/workspace_management/workspace_management_unit_test.dart b/test/features/workspace_management/workspace_management_unit_test.dart index 78b389d7..70d52a32 100644 --- a/test/features/workspace_management/workspace_management_unit_test.dart +++ b/test/features/workspace_management/workspace_management_unit_test.dart @@ -73,6 +73,9 @@ BRIDGE_PORT_443_OPEN=yes expect(info.isBridgePort80Available, isTrue); expect(info.bridgePort443Open, isTrue); expect(info.isBridgePort443Available, isTrue); + expect(info.displaySummary, contains('sudo 可用')); + expect(info.displaySummary, contains('主域名 DNS 已解析')); + expect(info.displaySummary, contains('桥接 443 端口当前空闲')); }); test('ansible parser maps human readable output to step events', () { From e7d0220b9ce5cec08e7fc4aff0a9856c687ed5bc Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 8 Jun 2026 18:35:08 +0800 Subject: [PATCH 09/15] Add default bridge save action --- .../workspace_management_panel.dart | 5 +- .../workspace_management_result.dart | 30 ++++++++++- .../workspace_management_widget_test.dart | 50 +++++++++++++++++++ 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/lib/features/workspace_management/workspace_management_panel.dart b/lib/features/workspace_management/workspace_management_panel.dart index 2c7e6fc2..b5540a25 100644 --- a/lib/features/workspace_management/workspace_management_panel.dart +++ b/lib/features/workspace_management/workspace_management_panel.dart @@ -242,7 +242,10 @@ class _WorkspaceManagementPanelState extends State { const SizedBox(height: 12), _LogPanel(controller: _controller), const SizedBox(height: 12), - WorkspaceManagementResult(controller: _controller), + WorkspaceManagementResult( + controller: _controller, + appController: widget.appController, + ), ], ), ), diff --git a/lib/features/workspace_management/workspace_management_result.dart b/lib/features/workspace_management/workspace_management_result.dart index 4e5b9a7a..791c4d9a 100644 --- a/lib/features/workspace_management/workspace_management_result.dart +++ b/lib/features/workspace_management/workspace_management_result.dart @@ -4,15 +4,22 @@ import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import '../../app/app_controller.dart'; import '../../i18n/app_language.dart'; +import '../../runtime/runtime_controllers.dart'; import 'workspace_management_i18n.dart'; import 'workspace_provision_controller.dart'; import 'workspace_provision_models.dart'; class WorkspaceManagementResult extends StatelessWidget { - const WorkspaceManagementResult({super.key, required this.controller}); + const WorkspaceManagementResult({ + super.key, + required this.controller, + required this.appController, + }); final WorkspaceProvisionController controller; + final AppController appController; @override Widget build(BuildContext context) { @@ -79,6 +86,13 @@ class WorkspaceManagementResult extends StatelessWidget { icon: const Icon(Icons.key_outlined), label: Text(appText('复制 Token', 'Copy token')), ), + OutlinedButton.icon( + onPressed: result == null + ? null + : () => _saveAsDefault(result), + icon: const Icon(Icons.bookmark_add_outlined), + label: Text(appText('设为默认', 'Set as default')), + ), OutlinedButton.icon( onPressed: result == null ? null : () => _downloadResult(result), icon: const Icon(Icons.download_outlined), @@ -107,6 +121,20 @@ class WorkspaceManagementResult extends StatelessWidget { await File(location.path).writeAsString(result.downloadText); } + Future _saveAsDefault(WorkspaceDeploymentResult result) async { + final settingsController = appController.settingsController; + final currentSettings = appController.settings; + final nextSettings = await settingsController.buildSavedAccountProfileSettings( + settings: currentSettings, + accountBaseUrl: currentSettings.accountBaseUrl, + accountIdentifier: currentSettings.accountUsername, + bridgeServerUrl: result.url, + bridgeToken: result.bridgeToken, + isManualBridge: true, + ); + await appController.saveSettings(nextSettings, refreshAfterSave: true); + } + Widget _failure(BuildContext context) { final theme = Theme.of(context); return Container( diff --git a/test/features/workspace_management/workspace_management_widget_test.dart b/test/features/workspace_management/workspace_management_widget_test.dart index 225203d6..d25b7706 100644 --- a/test/features/workspace_management/workspace_management_widget_test.dart +++ b/test/features/workspace_management/workspace_management_widget_test.dart @@ -109,6 +109,56 @@ void main() { expect(find.text('bridge-token-123'), findsOneWidget); expect(find.text('下载凭据'), findsOneWidget); }); + + testWidgets('success result can save deployed bridge as default', (tester) async { + final store = _MemorySecureConfigStore(); + final appController = _NoopAppController(store: store); + final provisionController = WorkspaceProvisionController( + executor: _FakeSshExecutor(), + ); + await tester.binding.setSurfaceSize(const Size(1200, 1400)); + addTearDown(() async { + await tester.binding.setSurfaceSize(null); + }); + addTearDown(() { + provisionController.dispose(); + appController.dispose(); + }); + provisionController.deploymentResult = const WorkspaceDeploymentResult( + url: 'https://acp-bridge.onwalk.net', + bridgeToken: 'save-token-123', + ); + provisionController.phase = ProvisionPhase.success; + + await tester.pumpWidget( + _buildApp( + WorkspaceManagementPanel( + appController: appController, + provisionController: provisionController, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.ensureVisible(find.text('设为默认')); + await tester.tap(find.text('设为默认')); + await tester.pumpAndSettle(); + + expect( + appController.settings.acpBridgeServerModeConfig.selfHosted.serverUrl, + 'https://acp-bridge.onwalk.net', + ); + expect( + await appController.settingsController.loadSecretValueByRef( + appController + .settings + .acpBridgeServerModeConfig + .selfHosted + .passwordRef, + ), + 'save-token-123', + ); + }); } Widget _buildApp(Widget child) { From bbbf52e87fde019849e54adeed615df4641bc584 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 8 Jun 2026 21:09:11 +0800 Subject: [PATCH 10/15] fix: isolate remote desktop webrtc sessions --- lib/features/desktop/desktop_client.dart | 12 +++++++----- lib/features/desktop/desktop_view.dart | 2 +- test/features/desktop/desktop_client_test.dart | 10 ++++++++++ 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/lib/features/desktop/desktop_client.dart b/lib/features/desktop/desktop_client.dart index 6e3b5fbd..2dc63d86 100644 --- a/lib/features/desktop/desktop_client.dart +++ b/lib/features/desktop/desktop_client.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart'; import '../../app/app_controller.dart'; +import '../../runtime/gateway_runtime_helpers.dart'; String desktopConnectionStateName(RTCPeerConnectionState state) { final value = state.toString().split('.').last; @@ -31,6 +32,10 @@ Map desktopOfferParams({ }; } +String desktopSessionId() { + return 'remote-desktop-${randomIdInternal()}'; +} + Future desktopRemoteVideoStreamForTrack( RTCTrackEvent event, { required Future Function(String label) createFallbackStream, @@ -242,11 +247,8 @@ class DesktopClient { } }; - // Add transceivers for receiving video and audio - await _peerConnection!.addTransceiver( - kind: RTCRtpMediaType.RTCRtpMediaTypeAudio, - init: RTCRtpTransceiverInit(direction: TransceiverDirection.RecvOnly), - ); + // Bridge publishes a video-only desktop stream; keep SDP m-line mapping + // simple so reconnects do not depend on rejected audio sections. await _peerConnection!.addTransceiver( kind: RTCRtpMediaType.RTCRtpMediaTypeVideo, init: RTCRtpTransceiverInit(direction: TransceiverDirection.RecvOnly), diff --git a/lib/features/desktop/desktop_view.dart b/lib/features/desktop/desktop_view.dart index 9b39d3db..c349a177 100644 --- a/lib/features/desktop/desktop_view.dart +++ b/lib/features/desktop/desktop_view.dart @@ -76,7 +76,7 @@ class _DesktopViewState extends State { _initRenderer(); _client = DesktopClient( controller: widget.controller, - sessionId: 'remote-desktop-session', + sessionId: desktopSessionId(), ); _inputHandler = DesktopInputHandler( onSendInput: (event) { diff --git a/test/features/desktop/desktop_client_test.dart b/test/features/desktop/desktop_client_test.dart index 366fe220..34ebf6ee 100644 --- a/test/features/desktop/desktop_client_test.dart +++ b/test/features/desktop/desktop_client_test.dart @@ -143,6 +143,16 @@ void main() { expect(params['height'], 720); }); + test('generates distinct desktop session ids for parallel app instances', () { + final first = desktopSessionId(); + final second = desktopSessionId(); + + expect(first, startsWith('remote-desktop-')); + expect(second, startsWith('remote-desktop-')); + expect(first, isNot(second)); + expect(first, isNot('remote-desktop-session')); + }); + test('uses bridge-provided remote stream when present', () async { var fallbackCreated = false; final providedStream = FakeMediaStream('provided-stream'); From 27fd3a32867204a0bb39dff9a920124d92dd7346 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Tue, 9 Jun 2026 10:46:58 +0800 Subject: [PATCH 11/15] fix: smooth remote desktop input over webrtc --- lib/features/desktop/desktop_client.dart | 12 +++ .../desktop/desktop_input_handler.dart | 42 ++++++++-- .../features/desktop/desktop_client_test.dart | 38 +++++++-- .../desktop/desktop_input_handler_test.dart | 80 +++++++++++++++++++ 4 files changed, 160 insertions(+), 12 deletions(-) diff --git a/lib/features/desktop/desktop_client.dart b/lib/features/desktop/desktop_client.dart index 2dc63d86..284f214b 100644 --- a/lib/features/desktop/desktop_client.dart +++ b/lib/features/desktop/desktop_client.dart @@ -32,6 +32,14 @@ Map desktopOfferParams({ }; } +bool desktopShouldDropInputEvent( + Map event, { + required int bufferedAmount, + int bufferedAmountLimit = 64 * 1024, +}) { + return event['type'] == 'mouse_move' && bufferedAmount > bufferedAmountLimit; +} + String desktopSessionId() { return 'remote-desktop-${randomIdInternal()}'; } @@ -326,6 +334,10 @@ class DesktopClient { final channel = _dataChannel; if (channel != null && channel.state == RTCDataChannelState.RTCDataChannelOpen) { + final bufferedAmount = channel.bufferedAmount ?? 0; + if (desktopShouldDropInputEvent(event, bufferedAmount: bufferedAmount)) { + return; + } final jsonStr = jsonEncode(event); channel.send(RTCDataChannelMessage(jsonStr)); } diff --git a/lib/features/desktop/desktop_input_handler.dart b/lib/features/desktop/desktop_input_handler.dart index b2575717..15b46ee2 100644 --- a/lib/features/desktop/desktop_input_handler.dart +++ b/lib/features/desktop/desktop_input_handler.dart @@ -2,11 +2,22 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; +typedef DesktopInputClock = int Function(); + class DesktopInputHandler { - DesktopInputHandler({required this.onSendInput}); + DesktopInputHandler({ + required this.onSendInput, + int moveIntervalMs = 16, + DesktopInputClock? nowMillis, + }) : moveIntervalMs = moveIntervalMs < 0 ? 0 : moveIntervalMs, + _nowMillis = nowMillis ?? (() => DateTime.now().millisecondsSinceEpoch); final void Function(Map event) onSendInput; + final int moveIntervalMs; + final DesktopInputClock _nowMillis; int _lastPressedButton = 1; // Default to left click + int? _lastMoveSentAtMs; + Offset? _lastMovePosition; void handlePointerMove( PointerEvent event, @@ -20,7 +31,7 @@ class DesktopInputHandler { ); if (position == null) return; - onSendInput({'type': 'mouse_move', 'x': position.dx, 'y': position.dy}); + _sendPointerMove(position); } void handlePointerDown( @@ -36,7 +47,7 @@ class DesktopInputHandler { if (position == null) return; // Send move event first to ensure click hits the exact coordinates - onSendInput({'type': 'mouse_move', 'x': position.dx, 'y': position.dy}); + _sendPointerMove(position, force: true); _lastPressedButton = _mapPointerButtons(event.buttons); @@ -79,6 +90,25 @@ class DesktopInputHandler { if (buttons & 2 != 0) return 3; // right click return 1; } + + void _sendPointerMove(Offset position, {bool force = false}) { + final lastPosition = _lastMovePosition; + if (!force && + lastPosition != null && + (position - lastPosition).distance < 0.001) { + return; + } + + final now = _nowMillis(); + final lastSentAt = _lastMoveSentAtMs; + if (!force && lastSentAt != null && now - lastSentAt < moveIntervalMs) { + return; + } + + _lastMoveSentAtMs = now; + _lastMovePosition = position; + onSendInput({'type': 'mouse_move', 'x': position.dx, 'y': position.dy}); + } } String? desktopKeyName(LogicalKeyboardKey key) { @@ -97,7 +127,7 @@ String? desktopKeyName(LogicalKeyboardKey key) { if (key == LogicalKeyboardKey.end) return 'End'; if (key == LogicalKeyboardKey.pageUp) return 'Page_Up'; if (key == LogicalKeyboardKey.pageDown) return 'Page_Down'; - + if (key == LogicalKeyboardKey.shiftLeft) return 'Shift_L'; if (key == LogicalKeyboardKey.shiftRight) return 'Shift_R'; if (key == LogicalKeyboardKey.controlLeft) return 'Control_L'; @@ -162,7 +192,9 @@ Offset? desktopContentPosition( }) { if (viewportSize.width <= 0 || viewportSize.height <= 0) return null; - if (contentSize == null || contentSize.width <= 0 || contentSize.height <= 0) { + if (contentSize == null || + contentSize.width <= 0 || + contentSize.height <= 0) { return Offset( (localPosition.dx / viewportSize.width).clamp(0.0, 1.0), (localPosition.dy / viewportSize.height).clamp(0.0, 1.0), diff --git a/test/features/desktop/desktop_client_test.dart b/test/features/desktop/desktop_client_test.dart index 34ebf6ee..1a104d3f 100644 --- a/test/features/desktop/desktop_client_test.dart +++ b/test/features/desktop/desktop_client_test.dart @@ -143,14 +143,38 @@ void main() { expect(params['height'], 720); }); - test('generates distinct desktop session ids for parallel app instances', () { - final first = desktopSessionId(); - final second = desktopSessionId(); + test( + 'generates distinct desktop session ids for parallel app instances', + () { + final first = desktopSessionId(); + final second = desktopSessionId(); - expect(first, startsWith('remote-desktop-')); - expect(second, startsWith('remote-desktop-')); - expect(first, isNot(second)); - expect(first, isNot('remote-desktop-session')); + expect(first, startsWith('remote-desktop-')); + expect(second, startsWith('remote-desktop-')); + expect(first, isNot(second)); + expect(first, isNot('remote-desktop-session')); + }, + ); + + test('drops only stale mouse moves when data channel is backed up', () { + expect( + desktopShouldDropInputEvent({ + 'type': 'mouse_move', + }, bufferedAmount: 80 * 1024), + isTrue, + ); + expect( + desktopShouldDropInputEvent({ + 'type': 'mouse_down', + }, bufferedAmount: 80 * 1024), + isFalse, + ); + expect( + desktopShouldDropInputEvent({ + 'type': 'mouse_move', + }, bufferedAmount: 1024), + isFalse, + ); }); test('uses bridge-provided remote stream when present', () async { diff --git a/test/features/desktop/desktop_input_handler_test.dart b/test/features/desktop/desktop_input_handler_test.dart index 99a798e2..ca03b278 100644 --- a/test/features/desktop/desktop_input_handler_test.dart +++ b/test/features/desktop/desktop_input_handler_test.dart @@ -1,5 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; import 'package:xworkmate/features/desktop/desktop_input_handler.dart'; void main() { @@ -73,4 +75,82 @@ void main() { expect(position, const Offset(0.5, 0.5)); }); }); + + group('DesktopInputHandler pointer flow control', () { + test('throttles pointer move events before they hit the data channel', () { + var now = 0; + final events = >[]; + final handler = DesktopInputHandler( + onSendInput: events.add, + nowMillis: () => now, + ); + + handler.handlePointerMove( + const PointerHoverEvent(position: Offset(10, 10)), + const Size(100, 100), + ); + now = 5; + handler.handlePointerMove( + const PointerHoverEvent(position: Offset(20, 20)), + const Size(100, 100), + ); + now = 16; + handler.handlePointerMove( + const PointerHoverEvent(position: Offset(30, 30)), + const Size(100, 100), + ); + + expect(events, hasLength(2)); + expect(events.first['x'], 0.1); + expect(events.last['x'], 0.3); + }); + + test('deduplicates unchanged pointer move positions', () { + var now = 0; + final events = >[]; + final handler = DesktopInputHandler( + onSendInput: events.add, + nowMillis: () => now, + ); + + handler.handlePointerMove( + const PointerHoverEvent(position: Offset(10, 10)), + const Size(100, 100), + ); + now = 100; + handler.handlePointerMove( + const PointerHoverEvent(position: Offset(10, 10)), + const Size(100, 100), + ); + + expect(events, hasLength(1)); + }); + + test('forces latest pointer position before mouse down', () { + var now = 0; + final events = >[]; + final handler = DesktopInputHandler( + onSendInput: events.add, + nowMillis: () => now, + ); + + handler.handlePointerMove( + const PointerHoverEvent(position: Offset(10, 10)), + const Size(100, 100), + ); + now = 5; + handler.handlePointerDown( + const PointerDownEvent( + position: Offset(80, 20), + buttons: kPrimaryMouseButton, + ), + const Size(100, 100), + ); + + expect(events, hasLength(3)); + expect(events[1], containsPair('type', 'mouse_move')); + expect(events[1]['x'], 0.8); + expect(events[2], containsPair('type', 'mouse_down')); + }); + }); } From a8cce8e32ce5d08543a9b27c52be1d76e90b1fd5 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Tue, 9 Jun 2026 10:58:15 +0800 Subject: [PATCH 12/15] feat: align workspace ready actions and naming --- .../settings/settings_account_panel.dart | 10 +-- .../workspace_management_result.dart | 72 +++++++++++++------ .../settings/settings_account_panel_test.dart | 6 +- .../workspace_management_widget_test.dart | 26 ++++--- 4 files changed, 76 insertions(+), 38 deletions(-) diff --git a/lib/features/settings/settings_account_panel.dart b/lib/features/settings/settings_account_panel.dart index d413ca7a..ad4e9f10 100644 --- a/lib/features/settings/settings_account_panel.dart +++ b/lib/features/settings/settings_account_panel.dart @@ -99,7 +99,7 @@ class _SettingsAccountPanelState extends State controller: _signedOutTabController, tabs: [ Tab(text: appText('svc.plus 云端同步', 'svc.plus Cloud Sync')), - Tab(text: appText('手动 Bridge 配置', 'Manual Bridge Config')), + Tab(text: appText('AI 智能体工作空间', 'AI Agentic Workspace')), ], ), const SizedBox(height: 24), @@ -189,15 +189,15 @@ class _ManualBridgePanel extends StatelessWidget { ), const SizedBox(height: 16), Text( - appText('手动 Bridge 配置', 'Manual Bridge Config'), + appText('AI 智能体工作空间', 'AI Agentic Workspace'), style: theme.textTheme.headlineMedium, textAlign: TextAlign.center, ), const SizedBox(height: 10), Text( appText( - '直接配置本地或私有 xworkmate-bridge 地址与令牌。', - 'Configure local or private xworkmate-bridge address and token directly.', + '直接配置本地或私有 AI 智能体工作空间地址与令牌。', + 'Configure a local or private AI Agentic Workspace address and token directly.', ), style: theme.textTheme.titleMedium?.copyWith( color: theme.textTheme.bodyMedium?.color?.withValues( @@ -799,7 +799,7 @@ String _connectionSourceLabel( ); return mode == _SignedInAccountMode.accountSync ? appText('svc.plus 托管配置', 'svc.plus managed profile') - : appText('手动 Bridge 配置', 'Manual Bridge configuration'); + : appText('AI 智能体工作空间', 'AI Agentic Workspace'); } class _TokenConfiguredSummary extends StatelessWidget { diff --git a/lib/features/workspace_management/workspace_management_result.dart b/lib/features/workspace_management/workspace_management_result.dart index 791c4d9a..b6c0c146 100644 --- a/lib/features/workspace_management/workspace_management_result.dart +++ b/lib/features/workspace_management/workspace_management_result.dart @@ -82,27 +82,34 @@ class WorkspaceManagementResult extends StatelessWidget { label: Text(WorkspaceManagementText.copyAddress), ), OutlinedButton.icon( - onPressed: () => Clipboard.setData(ClipboardData(text: token)), + onPressed: () => + Clipboard.setData(ClipboardData(text: token)), icon: const Icon(Icons.key_outlined), label: Text(appText('复制 Token', 'Copy token')), ), OutlinedButton.icon( onPressed: result == null ? null - : () => _saveAsDefault(result), - icon: const Icon(Icons.bookmark_add_outlined), - label: Text(appText('设为默认', 'Set as default')), - ), - OutlinedButton.icon( - onPressed: result == null ? null : () => _downloadResult(result), + : () => _downloadResult(result), icon: const Icon(Icons.download_outlined), label: Text(appText('下载凭据', 'Download credentials')), ), - FilledButton.tonalIcon( - onPressed: null, + FilledButton.icon( + onPressed: result == null + ? null + : () => _openWorkspace(result), icon: const Icon(Icons.settings_remote_outlined), label: Text(WorkspaceManagementText.connectToWorkspace), ), + FilledButton.icon( + onPressed: result == null + ? null + : () => _saveAsDefault(result), + icon: const Icon(Icons.bookmark_add_outlined), + label: Text( + appText('设为默认保存配置', 'Set as default and save config'), + ), + ), ], ), ], @@ -121,17 +128,41 @@ class WorkspaceManagementResult extends StatelessWidget { await File(location.path).writeAsString(result.downloadText); } + Future _openWorkspace(WorkspaceDeploymentResult result) async { + final url = result.url.trim(); + if (url.isEmpty) { + return; + } + try { + if (Platform.isMacOS) { + await Process.run('open', [url]); + return; + } + if (Platform.isWindows) { + await Process.run('cmd', ['/c', 'start', '', url]); + return; + } + if (Platform.isLinux) { + await Process.run('xdg-open', [url]); + } + } catch (error) { + debugPrint('Open workspace URL failed: $error'); + await Clipboard.setData(ClipboardData(text: url)); + } + } + Future _saveAsDefault(WorkspaceDeploymentResult result) async { final settingsController = appController.settingsController; final currentSettings = appController.settings; - final nextSettings = await settingsController.buildSavedAccountProfileSettings( - settings: currentSettings, - accountBaseUrl: currentSettings.accountBaseUrl, - accountIdentifier: currentSettings.accountUsername, - bridgeServerUrl: result.url, - bridgeToken: result.bridgeToken, - isManualBridge: true, - ); + final nextSettings = await settingsController + .buildSavedAccountProfileSettings( + settings: currentSettings, + accountBaseUrl: currentSettings.accountBaseUrl, + accountIdentifier: currentSettings.accountUsername, + bridgeServerUrl: result.url, + bridgeToken: result.bridgeToken, + isManualBridge: true, + ); await appController.saveSettings(nextSettings, refreshAfterSave: true); } @@ -143,7 +174,9 @@ class WorkspaceManagementResult extends StatelessWidget { decoration: BoxDecoration( color: theme.colorScheme.errorContainer.withValues(alpha: 0.45), borderRadius: BorderRadius.circular(8), - border: Border.all(color: theme.colorScheme.error.withValues(alpha: 0.35)), + border: Border.all( + color: theme.colorScheme.error.withValues(alpha: 0.35), + ), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -162,8 +195,7 @@ class WorkspaceManagementResult extends StatelessWidget { ), const SizedBox(height: 4), Text( - controller.errorMessage ?? - appText('请查看日志。', 'Check logs.'), + controller.errorMessage ?? appText('请查看日志。', 'Check logs.'), ), ], ), diff --git a/test/features/settings/settings_account_panel_test.dart b/test/features/settings/settings_account_panel_test.dart index bfca15a0..31c7e7ac 100644 --- a/test/features/settings/settings_account_panel_test.dart +++ b/test/features/settings/settings_account_panel_test.dart @@ -156,7 +156,7 @@ void main() { ), ); - await tester.tap(find.text('手动 Bridge 配置')); + await tester.tap(find.text('AI 智能体工作空间')); await tester.pump(); await tester.enterText( find.byKey(const ValueKey('settings-manual-bridge-url-field')), @@ -211,7 +211,7 @@ void main() { ), ); - await tester.tap(find.text('手动 Bridge 配置')); + await tester.tap(find.text('AI 智能体工作空间')); await tester.pump(); expect(saveCount, 0); @@ -265,7 +265,7 @@ void main() { ), ); - await tester.tap(find.text('手动 Bridge 配置')); + await tester.tap(find.text('AI 智能体工作空间')); await tester.pump(); await tester.enterText( find.byKey(const ValueKey('settings-manual-bridge-url-field')), diff --git a/test/features/workspace_management/workspace_management_widget_test.dart b/test/features/workspace_management/workspace_management_widget_test.dart index d25b7706..d4a13cb5 100644 --- a/test/features/workspace_management/workspace_management_widget_test.dart +++ b/test/features/workspace_management/workspace_management_widget_test.dart @@ -34,7 +34,10 @@ void main() { expect(find.text('创建 / 升级 AI 工作空间'), findsOneWidget); expect(find.text('workspace.example.com'), findsOneWidget); - expect(find.byKey(const Key('workspace-management-upgrade-button')), findsOneWidget); + expect( + find.byKey(const Key('workspace-management-upgrade-button')), + findsOneWidget, + ); expect( tester .widget( @@ -77,7 +80,10 @@ void main() { provisionController.updateForm(logsExpanded: true); await tester.pumpAndSettle(); - expect(find.byKey(const Key('workspace-management-log-content')), findsOneWidget); + expect( + find.byKey(const Key('workspace-management-log-content')), + findsOneWidget, + ); expect(find.textContaining('hello log'), findsOneWidget); }); @@ -108,9 +114,13 @@ void main() { expect(find.text('https://xworkmate-bridge.example.com'), findsOneWidget); expect(find.text('bridge-token-123'), findsOneWidget); expect(find.text('下载凭据'), findsOneWidget); + expect(find.text('连接到该工作空间'), findsOneWidget); + expect(find.text('设为默认保存配置'), findsOneWidget); }); - testWidgets('success result can save deployed bridge as default', (tester) async { + testWidgets('success result can save deployed bridge as default', ( + tester, + ) async { final store = _MemorySecureConfigStore(); final appController = _NoopAppController(store: store); final provisionController = WorkspaceProvisionController( @@ -140,8 +150,8 @@ void main() { ); await tester.pumpAndSettle(); - await tester.ensureVisible(find.text('设为默认')); - await tester.tap(find.text('设为默认')); + await tester.ensureVisible(find.text('设为默认保存配置')); + await tester.tap(find.text('设为默认保存配置')); await tester.pumpAndSettle(); expect( @@ -150,11 +160,7 @@ void main() { ); expect( await appController.settingsController.loadSecretValueByRef( - appController - .settings - .acpBridgeServerModeConfig - .selfHosted - .passwordRef, + appController.settings.acpBridgeServerModeConfig.selfHosted.passwordRef, ), 'save-token-123', ); From 372cf7cb8ef716f49ddecc865e8474d02bc77875 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Tue, 9 Jun 2026 15:20:45 +0800 Subject: [PATCH 13/15] fix: clear desktop first-frame overlay after decode --- lib/features/desktop/desktop_client.dart | 10 ++++ lib/features/desktop/desktop_view.dart | 50 +++++++++++++------ .../features/desktop/desktop_client_test.dart | 30 +++++++++++ 3 files changed, 74 insertions(+), 16 deletions(-) diff --git a/lib/features/desktop/desktop_client.dart b/lib/features/desktop/desktop_client.dart index 284f214b..ebe5b1bc 100644 --- a/lib/features/desktop/desktop_client.dart +++ b/lib/features/desktop/desktop_client.dart @@ -40,6 +40,16 @@ bool desktopShouldDropInputEvent( return event['type'] == 'mouse_move' && bufferedAmount > bufferedAmountLimit; } +bool desktopHasRenderedVideoFrame({ + required bool hasStream, + required int rendererVideoWidth, + required int rendererVideoHeight, + required bool hasDecodedFrames, +}) { + return hasStream && + (hasDecodedFrames || (rendererVideoWidth > 0 && rendererVideoHeight > 0)); +} + String desktopSessionId() { return 'remote-desktop-${randomIdInternal()}'; } diff --git a/lib/features/desktop/desktop_view.dart b/lib/features/desktop/desktop_view.dart index c349a177..b0ff4880 100644 --- a/lib/features/desktop/desktop_view.dart +++ b/lib/features/desktop/desktop_view.dart @@ -55,6 +55,7 @@ class _DesktopViewState extends State { bool _showControlPanel = true; String _connectionState = 'disconnected'; bool _hasStream = false; + bool _hasDecodedVideoFrame = false; bool _isFocused = false; Size _remoteDesktopSize = const Size(1280, 720); @@ -65,10 +66,12 @@ class _DesktopViewState extends State { StreamSubscription? _stateSubscription; Timer? _firstFrameStatsTimer; - bool get _hasVideoFrame => - _hasStream && - _localRenderer.videoWidth > 0 && - _localRenderer.videoHeight > 0; + bool get _hasVideoFrame => desktopHasRenderedVideoFrame( + hasStream: _hasStream, + rendererVideoWidth: _localRenderer.videoWidth, + rendererVideoHeight: _localRenderer.videoHeight, + hasDecodedFrames: _hasDecodedVideoFrame, + ); @override void initState() { @@ -91,6 +94,7 @@ class _DesktopViewState extends State { setState(() { _localRenderer.srcObject = stream; _hasStream = true; + _hasDecodedVideoFrame = false; }); _startFirstFrameDiagnostics(); } @@ -103,6 +107,7 @@ class _DesktopViewState extends State { if (_connectionState == 'disconnected' || _connectionState == 'failed') { _hasStream = false; + _hasDecodedVideoFrame = false; _localRenderer.srcObject = null; _stopFirstFrameDiagnostics(); } @@ -114,6 +119,9 @@ class _DesktopViewState extends State { Future _initRenderer() async { await _localRenderer.initialize(); _localRenderer.onResize = () { + if (_localRenderer.videoWidth > 0 && _localRenderer.videoHeight > 0) { + _hasDecodedVideoFrame = true; + } _stopFirstFrameDiagnostics(); if (mounted) { setState(() {}); @@ -123,25 +131,35 @@ class _DesktopViewState extends State { void _startFirstFrameDiagnostics() { _firstFrameStatsTimer?.cancel(); - _firstFrameStatsTimer = Timer.periodic(const Duration(seconds: 5), (_) { + unawaited(_collectFirstFrameStats()); + _firstFrameStatsTimer = Timer.periodic(const Duration(seconds: 2), (_) { if (!_hasStream || _hasVideoFrame || !mounted) { _stopFirstFrameDiagnostics(); return; } - unawaited(() async { - try { - final stats = await _client.collectVideoStats(); - if (stats == null) { - return; - } - debugPrint('Remote desktop waiting for first frame: $stats'); - } catch (error) { - debugPrint('Remote desktop stats failed: $error'); - } - }()); + unawaited(_collectFirstFrameStats()); }); } + Future _collectFirstFrameStats() async { + try { + final stats = await _client.collectVideoStats(); + if (stats == null || !mounted || !_hasStream) { + return; + } + if (stats.hasDecodedFrames) { + setState(() { + _hasDecodedVideoFrame = true; + }); + _stopFirstFrameDiagnostics(); + return; + } + debugPrint('Remote desktop waiting for first frame: $stats'); + } catch (error) { + debugPrint('Remote desktop stats failed: $error'); + } + } + void _stopFirstFrameDiagnostics() { _firstFrameStatsTimer?.cancel(); _firstFrameStatsTimer = null; diff --git a/test/features/desktop/desktop_client_test.dart b/test/features/desktop/desktop_client_test.dart index 1a104d3f..76ae0c41 100644 --- a/test/features/desktop/desktop_client_test.dart +++ b/test/features/desktop/desktop_client_test.dart @@ -177,6 +177,36 @@ void main() { ); }); + test('treats decoded video stats as a rendered first frame', () { + expect( + desktopHasRenderedVideoFrame( + hasStream: true, + rendererVideoWidth: 0, + rendererVideoHeight: 0, + hasDecodedFrames: true, + ), + isTrue, + ); + expect( + desktopHasRenderedVideoFrame( + hasStream: true, + rendererVideoWidth: 1280, + rendererVideoHeight: 720, + hasDecodedFrames: false, + ), + isTrue, + ); + expect( + desktopHasRenderedVideoFrame( + hasStream: false, + rendererVideoWidth: 1280, + rendererVideoHeight: 720, + hasDecodedFrames: true, + ), + isFalse, + ); + }); + test('uses bridge-provided remote stream when present', () async { var fallbackCreated = false; final providedStream = FakeMediaStream('provided-stream'); From 722057de0b5f5719d74bdc0cb30c0f1c35582fe6 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Tue, 9 Jun 2026 15:25:42 +0800 Subject: [PATCH 14/15] fix: use renderer first-frame signal for desktop video --- lib/features/desktop/desktop_view.dart | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/lib/features/desktop/desktop_view.dart b/lib/features/desktop/desktop_view.dart index b0ff4880..b8ef8a29 100644 --- a/lib/features/desktop/desktop_view.dart +++ b/lib/features/desktop/desktop_view.dart @@ -118,17 +118,31 @@ class _DesktopViewState extends State { Future _initRenderer() async { await _localRenderer.initialize(); + _localRenderer.onFirstFrameRendered = () { + _markRemoteDesktopFrameReady(); + }; _localRenderer.onResize = () { if (_localRenderer.videoWidth > 0 && _localRenderer.videoHeight > 0) { - _hasDecodedVideoFrame = true; + _markRemoteDesktopFrameReady(); + return; } - _stopFirstFrameDiagnostics(); if (mounted) { setState(() {}); } }; } + void _markRemoteDesktopFrameReady() { + if (!_hasStream || _hasDecodedVideoFrame) { + return; + } + _hasDecodedVideoFrame = true; + _stopFirstFrameDiagnostics(); + if (mounted) { + setState(() {}); + } + } + void _startFirstFrameDiagnostics() { _firstFrameStatsTimer?.cancel(); unawaited(_collectFirstFrameStats()); @@ -148,10 +162,7 @@ class _DesktopViewState extends State { return; } if (stats.hasDecodedFrames) { - setState(() { - _hasDecodedVideoFrame = true; - }); - _stopFirstFrameDiagnostics(); + _markRemoteDesktopFrameReady(); return; } debugPrint('Remote desktop waiting for first frame: $stats'); @@ -171,6 +182,8 @@ class _DesktopViewState extends State { _streamSubscription?.cancel(); _stateSubscription?.cancel(); _client.disconnect(); + _localRenderer.onResize = null; + _localRenderer.onFirstFrameRendered = null; _localRenderer.dispose(); _displayController.dispose(); _widthController.dispose(); From 0cd6d3e4a99423e27096fd15940d7c7545f18edb Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Tue, 9 Jun 2026 15:54:36 +0800 Subject: [PATCH 15/15] fix: split desktop mouse move data channel --- lib/features/desktop/desktop_client.dart | 55 +++++++++++++++---- .../features/desktop/desktop_client_test.dart | 32 ++++++++++- 2 files changed, 76 insertions(+), 11 deletions(-) diff --git a/lib/features/desktop/desktop_client.dart b/lib/features/desktop/desktop_client.dart index ebe5b1bc..8c6cb1a0 100644 --- a/lib/features/desktop/desktop_client.dart +++ b/lib/features/desktop/desktop_client.dart @@ -5,6 +5,13 @@ import 'package:flutter_webrtc/flutter_webrtc.dart'; import '../../app/app_controller.dart'; import '../../runtime/gateway_runtime_helpers.dart'; +const String desktopReliableInputChannelLabel = 'input'; +const String desktopMoveInputChannelLabel = 'input-move'; +const int desktopReliableInputChannelId = 0; +const int desktopMoveInputChannelId = 1; +const int desktopMoveChannelMaxPacketLifeTimeMs = 100; +const int desktopMoveBufferedAmountLimit = 16 * 1024; + String desktopConnectionStateName(RTCPeerConnectionState state) { final value = state.toString().split('.').last; return value.replaceFirst('RTCPeerConnectionState', '').toLowerCase(); @@ -35,11 +42,30 @@ Map desktopOfferParams({ bool desktopShouldDropInputEvent( Map event, { required int bufferedAmount, - int bufferedAmountLimit = 64 * 1024, + int bufferedAmountLimit = desktopMoveBufferedAmountLimit, }) { return event['type'] == 'mouse_move' && bufferedAmount > bufferedAmountLimit; } +String desktopInputChannelLabelForEvent(Map event) { + return event['type'] == 'mouse_move' + ? desktopMoveInputChannelLabel + : desktopReliableInputChannelLabel; +} + +RTCDataChannelInit desktopReliableInputChannelConfig() { + return RTCDataChannelInit() + ..ordered = true + ..id = desktopReliableInputChannelId; +} + +RTCDataChannelInit desktopMoveInputChannelConfig() { + return RTCDataChannelInit() + ..ordered = false + ..id = desktopMoveInputChannelId + ..maxRetransmitTime = desktopMoveChannelMaxPacketLifeTimeMs; +} + bool desktopHasRenderedVideoFrame({ required bool hasStream, required int rendererVideoWidth, @@ -179,7 +205,8 @@ class DesktopClient { final String sessionId; RTCPeerConnection? _peerConnection; - RTCDataChannel? _dataChannel; + RTCDataChannel? _inputChannel; + RTCDataChannel? _moveInputChannel; final StreamController _streamController = StreamController.broadcast(); @@ -244,11 +271,14 @@ class DesktopClient { _stateController.add(desktopConnectionStateName(state)); }; - // Create data channel for inputs BEFORE creating offer - final dcConfig = RTCDataChannelInit()..ordered = true; - _dataChannel = await _peerConnection!.createDataChannel( - 'input', - dcConfig, + // Create input data channels BEFORE creating the offer. + _inputChannel = await _peerConnection!.createDataChannel( + desktopReliableInputChannelLabel, + desktopReliableInputChannelConfig(), + ); + _moveInputChannel = await _peerConnection!.createDataChannel( + desktopMoveInputChannelLabel, + desktopMoveInputChannelConfig(), ); // Handle ICE Candidates generated locally @@ -341,7 +371,10 @@ class DesktopClient { } void sendInput(Map event) { - final channel = _dataChannel; + final channel = + desktopInputChannelLabelForEvent(event) == desktopMoveInputChannelLabel + ? (_moveInputChannel ?? _inputChannel) + : _inputChannel; if (channel != null && channel.state == RTCDataChannelState.RTCDataChannelOpen) { final bufferedAmount = channel.bufferedAmount ?? 0; @@ -363,9 +396,11 @@ class DesktopClient { debugPrint('Desktop close request failed: $error'); } - await _dataChannel?.close(); + await _moveInputChannel?.close(); + await _inputChannel?.close(); await _peerConnection?.close(); - _dataChannel = null; + _moveInputChannel = null; + _inputChannel = null; _peerConnection = null; _stateController.add('disconnected'); } diff --git a/test/features/desktop/desktop_client_test.dart b/test/features/desktop/desktop_client_test.dart index 76ae0c41..992e6ece 100644 --- a/test/features/desktop/desktop_client_test.dart +++ b/test/features/desktop/desktop_client_test.dart @@ -160,7 +160,7 @@ void main() { expect( desktopShouldDropInputEvent({ 'type': 'mouse_move', - }, bufferedAmount: 80 * 1024), + }, bufferedAmount: desktopMoveBufferedAmountLimit + 1), isTrue, ); expect( @@ -177,6 +177,36 @@ void main() { ); }); + test('routes mouse moves to the low-latency input channel', () { + expect( + desktopInputChannelLabelForEvent({'type': 'mouse_move'}), + desktopMoveInputChannelLabel, + ); + expect( + desktopInputChannelLabelForEvent({'type': 'mouse_down'}), + desktopReliableInputChannelLabel, + ); + expect( + desktopInputChannelLabelForEvent({'type': 'key_down'}), + desktopReliableInputChannelLabel, + ); + }); + + test('configures mouse move channel for low-latency delivery', () { + final reliableConfig = desktopReliableInputChannelConfig(); + final moveConfig = desktopMoveInputChannelConfig(); + + expect(reliableConfig.ordered, isTrue); + expect(reliableConfig.id, desktopReliableInputChannelId); + expect(moveConfig.ordered, isFalse); + expect(moveConfig.id, desktopMoveInputChannelId); + expect( + moveConfig.maxRetransmitTime, + desktopMoveChannelMaxPacketLifeTimeMs, + ); + expect(moveConfig.toMap()['maxPacketLifeTime'], 100); + }); + test('treats decoded video stats as a rendered first frame', () { expect( desktopHasRenderedVideoFrame(