|
| 1 | +import 'dart:convert'; |
| 2 | +import 'package:flutter/material.dart'; |
| 3 | +import 'package:xterm/xterm.dart'; |
| 4 | +import 'package:flutter_pty/flutter_pty.dart'; |
| 5 | +import '../services/terminal_service.dart'; |
| 6 | +import '../services/preferences_service.dart'; |
| 7 | +import 'dashboard_screen.dart'; |
| 8 | + |
| 9 | +/// Runs `openclaw onboarding` in a terminal so the user can configure |
| 10 | +/// API keys and select loopback binding. Shown after first-time setup |
| 11 | +/// and accessible from the dashboard for re-configuration. |
| 12 | +class OnboardingScreen extends StatefulWidget { |
| 13 | + /// If true, shows a "Go to Dashboard" button when onboarding exits. |
| 14 | + /// Used after first-time setup. If false, just pops back. |
| 15 | + final bool isFirstRun; |
| 16 | + |
| 17 | + const OnboardingScreen({super.key, this.isFirstRun = false}); |
| 18 | + |
| 19 | + @override |
| 20 | + State<OnboardingScreen> createState() => _OnboardingScreenState(); |
| 21 | +} |
| 22 | + |
| 23 | +class _OnboardingScreenState extends State<OnboardingScreen> { |
| 24 | + late final Terminal _terminal; |
| 25 | + Pty? _pty; |
| 26 | + bool _loading = true; |
| 27 | + bool _finished = false; |
| 28 | + String? _error; |
| 29 | + |
| 30 | + @override |
| 31 | + void initState() { |
| 32 | + super.initState(); |
| 33 | + _terminal = Terminal(maxLines: 10000); |
| 34 | + _startOnboarding(); |
| 35 | + } |
| 36 | + |
| 37 | + Future<void> _startOnboarding() async { |
| 38 | + try { |
| 39 | + final config = await TerminalService.getProotShellConfig(); |
| 40 | + final args = TerminalService.buildProotArgs(config); |
| 41 | + |
| 42 | + // Replace the login shell with a command that runs onboarding |
| 43 | + // buildProotArgs ends with ['/bin/bash', '-l'] |
| 44 | + // Replace with ['/bin/bash', '-lc', 'openclaw onboarding'] |
| 45 | + final onboardingArgs = List<String>.from(args); |
| 46 | + // Remove last two entries ('/bin/bash', '-l') and replace |
| 47 | + onboardingArgs.removeLast(); // remove '-l' |
| 48 | + onboardingArgs.removeLast(); // remove '/bin/bash' |
| 49 | + onboardingArgs.addAll([ |
| 50 | + '/bin/bash', '-lc', |
| 51 | + 'echo "=== OpenClaw Onboarding ===" && ' |
| 52 | + 'echo "Configure your API keys and binding settings." && ' |
| 53 | + 'echo "TIP: Select Loopback (127.0.0.1) when asked for binding!" && ' |
| 54 | + 'echo "" && ' |
| 55 | + 'openclaw onboarding; ' |
| 56 | + 'echo "" && echo "Onboarding complete! You can close this screen."', |
| 57 | + ]); |
| 58 | + |
| 59 | + _pty = Pty.start( |
| 60 | + config['executable']!, |
| 61 | + arguments: onboardingArgs, |
| 62 | + environment: { |
| 63 | + 'PROOT_TMP_DIR': config['PROOT_TMP_DIR']!, |
| 64 | + 'HOME': '/root', |
| 65 | + 'TERM': 'xterm-256color', |
| 66 | + 'LANG': 'en_US.UTF-8', |
| 67 | + }, |
| 68 | + columns: _terminal.viewWidth, |
| 69 | + rows: _terminal.viewHeight, |
| 70 | + ); |
| 71 | + |
| 72 | + _pty!.output.cast<List<int>>().listen((data) { |
| 73 | + _terminal.write(String.fromCharCodes(data)); |
| 74 | + }); |
| 75 | + |
| 76 | + _pty!.exitCode.then((code) { |
| 77 | + _terminal.write('\r\n[Onboarding exited with code $code]\r\n'); |
| 78 | + if (mounted) { |
| 79 | + setState(() => _finished = true); |
| 80 | + } |
| 81 | + }); |
| 82 | + |
| 83 | + _terminal.onOutput = (data) { |
| 84 | + _pty?.write(utf8.encode(data)); |
| 85 | + }; |
| 86 | + |
| 87 | + _terminal.onResize = (w, h, pw, ph) { |
| 88 | + _pty?.resize(h, w); |
| 89 | + }; |
| 90 | + |
| 91 | + setState(() => _loading = false); |
| 92 | + } catch (e) { |
| 93 | + setState(() { |
| 94 | + _loading = false; |
| 95 | + _error = 'Failed to start onboarding: $e'; |
| 96 | + }); |
| 97 | + } |
| 98 | + } |
| 99 | + |
| 100 | + @override |
| 101 | + void dispose() { |
| 102 | + _pty?.kill(); |
| 103 | + super.dispose(); |
| 104 | + } |
| 105 | + |
| 106 | + Future<void> _goToDashboard() async { |
| 107 | + final navigator = Navigator.of(context); |
| 108 | + final prefs = PreferencesService(); |
| 109 | + await prefs.init(); |
| 110 | + prefs.setupComplete = true; |
| 111 | + prefs.isFirstRun = false; |
| 112 | + |
| 113 | + if (mounted) { |
| 114 | + navigator.pushReplacement( |
| 115 | + MaterialPageRoute(builder: (_) => const DashboardScreen()), |
| 116 | + ); |
| 117 | + } |
| 118 | + } |
| 119 | + |
| 120 | + @override |
| 121 | + Widget build(BuildContext context) { |
| 122 | + return Scaffold( |
| 123 | + appBar: AppBar( |
| 124 | + title: const Text('OpenClaw Onboarding'), |
| 125 | + leading: widget.isFirstRun |
| 126 | + ? null // no back button during first-run |
| 127 | + : IconButton( |
| 128 | + icon: const Icon(Icons.arrow_back), |
| 129 | + onPressed: () => Navigator.of(context).pop(), |
| 130 | + ), |
| 131 | + automaticallyImplyLeading: false, |
| 132 | + ), |
| 133 | + body: Column( |
| 134 | + children: [ |
| 135 | + if (_loading) |
| 136 | + const Expanded( |
| 137 | + child: Center( |
| 138 | + child: Column( |
| 139 | + mainAxisAlignment: MainAxisAlignment.center, |
| 140 | + children: [ |
| 141 | + CircularProgressIndicator(), |
| 142 | + SizedBox(height: 16), |
| 143 | + Text('Starting onboarding...'), |
| 144 | + ], |
| 145 | + ), |
| 146 | + ), |
| 147 | + ) |
| 148 | + else if (_error != null) |
| 149 | + Expanded( |
| 150 | + child: Center( |
| 151 | + child: Padding( |
| 152 | + padding: const EdgeInsets.all(24), |
| 153 | + child: Column( |
| 154 | + mainAxisAlignment: MainAxisAlignment.center, |
| 155 | + children: [ |
| 156 | + Icon( |
| 157 | + Icons.error_outline, |
| 158 | + size: 48, |
| 159 | + color: Theme.of(context).colorScheme.error, |
| 160 | + ), |
| 161 | + const SizedBox(height: 16), |
| 162 | + Text( |
| 163 | + _error!, |
| 164 | + textAlign: TextAlign.center, |
| 165 | + style: TextStyle(color: Theme.of(context).colorScheme.error), |
| 166 | + ), |
| 167 | + const SizedBox(height: 16), |
| 168 | + FilledButton.icon( |
| 169 | + onPressed: () { |
| 170 | + setState(() { |
| 171 | + _loading = true; |
| 172 | + _error = null; |
| 173 | + _finished = false; |
| 174 | + }); |
| 175 | + _startOnboarding(); |
| 176 | + }, |
| 177 | + icon: const Icon(Icons.refresh), |
| 178 | + label: const Text('Retry'), |
| 179 | + ), |
| 180 | + ], |
| 181 | + ), |
| 182 | + ), |
| 183 | + ), |
| 184 | + ) |
| 185 | + else |
| 186 | + Expanded( |
| 187 | + child: TerminalView( |
| 188 | + _terminal, |
| 189 | + textStyle: const TerminalStyle( |
| 190 | + fontSize: 14, |
| 191 | + fontFamily: 'monospace', |
| 192 | + ), |
| 193 | + ), |
| 194 | + ), |
| 195 | + if (_finished) |
| 196 | + Padding( |
| 197 | + padding: const EdgeInsets.all(16), |
| 198 | + child: SizedBox( |
| 199 | + width: double.infinity, |
| 200 | + child: FilledButton.icon( |
| 201 | + onPressed: widget.isFirstRun |
| 202 | + ? _goToDashboard |
| 203 | + : () => Navigator.of(context).pop(), |
| 204 | + icon: Icon(widget.isFirstRun |
| 205 | + ? Icons.arrow_forward |
| 206 | + : Icons.check), |
| 207 | + label: Text(widget.isFirstRun |
| 208 | + ? 'Go to Dashboard' |
| 209 | + : 'Done'), |
| 210 | + ), |
| 211 | + ), |
| 212 | + ), |
| 213 | + ], |
| 214 | + ), |
| 215 | + ); |
| 216 | + } |
| 217 | +} |
0 commit comments