Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion flutter_app/lib/models/optional_package.dart
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,30 @@ class OptionalPackage {
completionSentinel: 'BREW_INSTALL_COMPLETE',
);

static const sshPackage = OptionalPackage(
id: 'ssh',
name: 'OpenSSH',
description: 'SSH client and server for secure remote access',
icon: Icons.vpn_key,
color: Colors.teal,
installCommand:
'set -e; '
'echo ">>> Installing OpenSSH..."; '
'apt-get update -qq && apt-get install -y openssh-client openssh-server; '
'ssh -V; '
'echo ">>> SSH_INSTALL_COMPLETE"',
uninstallCommand:
'set -e; '
'echo ">>> Removing OpenSSH..."; '
'apt-get remove -y openssh-client openssh-server && apt-get autoremove -y; '
'echo ">>> SSH_UNINSTALL_COMPLETE"',
checkPath: 'usr/bin/ssh',
estimatedSize: '~10 MB',
completionSentinel: 'SSH_INSTALL_COMPLETE',
);

/// All available optional packages.
static const all = [goPackage, brewPackage];
static const all = [goPackage, brewPackage, sshPackage];

/// Sentinel for uninstall completion (derived from install sentinel).
String get uninstallSentinel =>
Expand Down
323 changes: 323 additions & 0 deletions flutter_app/lib/screens/configure_screen.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,323 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:xterm/xterm.dart';
import 'package:flutter_pty/flutter_pty.dart';
import 'package:url_launcher/url_launcher.dart';
import '../services/native_bridge.dart';
import '../services/terminal_service.dart';
import '../widgets/terminal_toolbar.dart';

/// Runs `openclaw configure` in a terminal so the user can manage
/// gateway settings. Accessible from the dashboard.
class ConfigureScreen extends StatefulWidget {
const ConfigureScreen({super.key});

@override
State<ConfigureScreen> createState() => _ConfigureScreenState();
}

class _ConfigureScreenState extends State<ConfigureScreen> {
late final Terminal _terminal;
late final TerminalController _controller;
Pty? _pty;
bool _loading = true;
bool _finished = false;
String? _error;
static final _anyUrlRegex = RegExp(r'https?://[^\s<>\[\]"' "'" r'\)]+');
static final _boxDrawing = RegExp(r'[│┤├┬┴┼╮╯╰╭─╌╴╶┌┐└┘◇◆]+');

static const _fontFallback = [
'monospace',
'Noto Sans Mono',
'Noto Sans Mono CJK SC',
'Noto Sans Mono CJK TC',
'Noto Sans Mono CJK JP',
'Noto Color Emoji',
'Noto Sans Symbols',
'Noto Sans Symbols 2',
'sans-serif',
];

@override
void initState() {
super.initState();
_terminal = Terminal(maxLines: 10000);
_controller = TerminalController();
NativeBridge.startTerminalService();
WidgetsBinding.instance.addPostFrameCallback((_) {
_startConfigure();
});
}

Future<void> _startConfigure() async {
try {
final config = await TerminalService.getProotShellConfig();
final args = TerminalService.buildProotArgs(
config,
columns: _terminal.viewWidth,
rows: _terminal.viewHeight,
);

final configureArgs = List<String>.from(args);
configureArgs.removeLast(); // remove '-l'
configureArgs.removeLast(); // remove '/bin/bash'
configureArgs.addAll([
'/bin/bash', '-lc',
'echo "=== OpenClaw Configure ===" && '
'echo "Manage your gateway settings." && '
'echo "" && '
'openclaw configure; '
'echo "" && echo "Configuration complete! You can close this screen."',
]);

_pty = Pty.start(
config['executable']!,
arguments: configureArgs,
environment: TerminalService.buildHostEnv(config),
columns: _terminal.viewWidth,
rows: _terminal.viewHeight,
);

_pty!.output.cast<List<int>>().listen((data) {
_terminal.write(utf8.decode(data, allowMalformed: true));
});

_pty!.exitCode.then((code) {
_terminal.write('\r\n[Configure exited with code $code]\r\n');
if (mounted) {
setState(() => _finished = true);
}
});

_terminal.onOutput = (data) {
_pty?.write(utf8.encode(data));
};

_terminal.onResize = (w, h, pw, ph) {
_pty?.resize(h, w);
};

setState(() => _loading = false);
} catch (e) {
setState(() {
_loading = false;
_error = 'Failed to start configure: $e';
});
}
}

@override
void dispose() {
_controller.dispose();
_pty?.kill();
NativeBridge.stopTerminalService();
super.dispose();
}

String? _getSelectedText() {
final selection = _controller.selection;
if (selection == null || selection.isCollapsed) return null;

final range = selection.normalized;
final sb = StringBuffer();
for (int y = range.begin.y; y <= range.end.y; y++) {
if (y >= _terminal.buffer.lines.length) break;
final line = _terminal.buffer.lines[y];
final from = (y == range.begin.y) ? range.begin.x : 0;
final to = (y == range.end.y) ? range.end.x : null;
sb.write(line.getText(from, to));
if (y < range.end.y) sb.writeln();
}
final text = sb.toString().trim();
return text.isEmpty ? null : text;
}

String? _extractUrl(String text) {
final clean = text.replaceAll(_boxDrawing, '').replaceAll(RegExp(r'\s+'), '');
final parts = clean.split(RegExp(r'(?=https?://)'));
String? best;
for (final part in parts) {
final match = _anyUrlRegex.firstMatch(part);
if (match != null) {
final url = match.group(0)!;
if (best == null || url.length > best.length) {
best = url;
}
}
}
return best;
}

void _copySelection() {
final text = _getSelectedText();
if (text == null) return;

Clipboard.setData(ClipboardData(text: text));

final url = _extractUrl(text);
if (url != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Copied to clipboard'),
duration: const Duration(seconds: 3),
action: SnackBarAction(
label: 'Open',
onPressed: () {
final uri = Uri.tryParse(url);
if (uri != null) {
launchUrl(uri, mode: LaunchMode.externalApplication);
}
},
),
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Copied to clipboard'),
duration: Duration(seconds: 1),
),
);
}
}

void _openSelection() {
final text = _getSelectedText();
if (text == null) return;

final url = _extractUrl(text);
if (url != null) {
final uri = Uri.tryParse(url);
if (uri != null) {
launchUrl(uri, mode: LaunchMode.externalApplication);
return;
}
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('No URL found in selection'),
duration: Duration(seconds: 1),
),
);
}

Future<void> _paste() async {
final data = await Clipboard.getData(Clipboard.kTextPlain);
if (data?.text != null && data!.text!.isNotEmpty) {
_pty?.write(utf8.encode(data.text!));
}
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('OpenClaw Configure'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).pop(),
),
automaticallyImplyLeading: false,
actions: [
IconButton(
icon: const Icon(Icons.copy),
tooltip: 'Copy',
onPressed: _copySelection,
),
IconButton(
icon: const Icon(Icons.open_in_browser),
tooltip: 'Open URL',
onPressed: _openSelection,
),
IconButton(
icon: const Icon(Icons.paste),
tooltip: 'Paste',
onPressed: _paste,
),
],
),
body: Column(
children: [
if (_loading)
const Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Starting configure...'),
],
),
),
)
else if (_error != null)
Expanded(
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 48,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
_error!,
textAlign: TextAlign.center,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: () {
setState(() {
_loading = true;
_error = null;
_finished = false;
});
_startConfigure();
},
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
),
),
)
else ...[
Expanded(
child: TerminalView(
_terminal,
controller: _controller,
textStyle: const TerminalStyle(
fontSize: 11,
height: 1.0,
fontFamily: 'DejaVuSansMono',
fontFamilyFallback: _fontFallback,
),
),
),
TerminalToolbar(pty: _pty),
],
if (_finished)
Padding(
padding: const EdgeInsets.all(16),
child: SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.check),
label: const Text('Done'),
),
),
),
],
),
);
}
}
12 changes: 11 additions & 1 deletion flutter_app/lib/screens/dashboard_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import '../providers/node_provider.dart';
import '../widgets/gateway_controls.dart';
import '../widgets/status_card.dart';
import 'node_screen.dart';
import 'configure_screen.dart';
import 'onboarding_screen.dart';
import 'terminal_screen.dart';
import 'web_dashboard_screen.dart';
Expand Down Expand Up @@ -89,9 +90,18 @@ class DashboardScreen extends StatelessWidget {
MaterialPageRoute(builder: (_) => const OnboardingScreen()),
),
),
StatusCard(
title: 'Configure',
subtitle: 'Manage gateway settings',
icon: Icons.tune,
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const ConfigureScreen()),
),
),
StatusCard(
title: 'Packages',
subtitle: 'Install optional tools (Go, Homebrew)',
subtitle: 'Install optional tools (Go, Homebrew, SSH)',
icon: Icons.extension,
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(
Expand Down
Loading
Loading