diff --git a/assets/images/console.svg b/assets/images/console.svg new file mode 100644 index 0000000..8c555d8 --- /dev/null +++ b/assets/images/console.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/lib/src/pages/manager.dart b/lib/src/pages/manager.dart index 9e95aeb..734142d 100644 --- a/lib/src/pages/manager.dart +++ b/lib/src/pages/manager.dart @@ -1,8 +1,10 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:core'; import 'package:flutter/material.dart'; import 'package:path/path.dart' as path; import 'package:file_picker/file_picker.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'dart:io'; import '../globals.dart'; @@ -23,12 +25,16 @@ class Manager extends StatefulWidget { class _ManagerState extends State with PreferencesMixin { List _currentVms = []; Map _activeVms = {}; - final List _spicyVms = []; + bool _spicy = false; + final List _sshVms = []; + String? _terminalEmulator; Timer? refreshTimer; @override void initState() { super.initState(); + _getTerminalEmulator(); + _detectSpice(); getPreference(prefWorkingDirectory).then((pref) { setState(() { if (pref == null) { @@ -38,7 +44,6 @@ class _ManagerState extends State with PreferencesMixin { }); Future.delayed(Duration.zero, () => _getVms(context)); // Reload VM list when we enter the page. }); - refreshTimer = Timer.periodic(const Duration(seconds: 5), (Timer t) { _getVms(context); }); // Reload VM list every 5 seconds. @@ -50,6 +55,24 @@ class _ManagerState extends State with PreferencesMixin { super.dispose(); } + void _getTerminalEmulator() async { + ProcessResult result = Process.runSync('x-terminal-emulator', ['-h']); + RegExp pattern = RegExp(r"usage:\s+([^\s]+)", multiLine: true, caseSensitive: false); + RegExpMatch? match = pattern.firstMatch(result.stdout); + if (match != null) { + setState(() { + _terminalEmulator = match.group(1); + }); + } + } + + void _detectSpice() async { + ProcessResult result = await Process.run('which', ['spicy']); + setState(() { + _spicy = result.exitCode == 0; + }); + } + VmInfo _parseVmInfo(name) { VmInfo info = VmInfo(); List lines = File(name + '/' + name + '.ports').readAsLinesSync(); @@ -107,8 +130,21 @@ class _ManagerState extends State with PreferencesMixin { }); } + Future _detectSsh(int port) async { + bool isSSH = false; + try { + Socket socket = await Socket.connect('localhost', port); + isSSH = await socket.any((event) => utf8.decode(event).contains('SSH')); + socket.close(); + return isSSH; + } catch (exception) { + return false; + } + } + Widget _buildVmList() { List _widgetList = []; + final Color buttonColor = Theme.of(context).brightness == Brightness.dark ? Colors.white70 : Theme.of(context).colorScheme.primary; _widgetList.add( Row( mainAxisAlignment: MainAxisAlignment.center, @@ -122,7 +158,7 @@ class _ManagerState extends State with PreferencesMixin { ElevatedButton( style: ElevatedButton.styleFrom( primary: Theme.of(context).canvasColor, - onPrimary: Theme.of(context).brightness == Brightness.dark ? Colors.white70 : Theme.of(context).colorScheme.primary, + onPrimary: buttonColor ), onPressed: () async { String? result = await FilePicker.platform.getDirectoryPath(); @@ -141,7 +177,7 @@ class _ManagerState extends State with PreferencesMixin { ), ); List> rows = _currentVms.map((vm) { - return _buildRow(vm); + return _buildRow(vm, buttonColor); }).toList(); for (var row in rows) { _widgetList.addAll(row); @@ -153,18 +189,30 @@ class _ManagerState extends State with PreferencesMixin { ); } - List _buildRow(String currentVm) { + List _buildRow(String currentVm, Color buttonColor) { final bool active = _activeVms.containsKey(currentVm); - final bool spicy = _spicyVms.contains(currentVm); + final bool sshy = _sshVms.contains(currentVm); + VmInfo vmInfo = VmInfo(); String connectInfo = ''; if (active) { - VmInfo vmInfo = _activeVms[currentVm]!; - if (vmInfo.sshPort != null) { - connectInfo += context.t('SSH port') + ': ' + vmInfo.sshPort! + ' '; - } + vmInfo = _activeVms[currentVm]!; if (vmInfo.spicePort != null) { connectInfo += context.t('SPICE port') + ': ' + vmInfo.spicePort! + ' '; } + if (vmInfo.sshPort != null && _terminalEmulator != null) { + connectInfo += context.t('SSH port') + ': ' + vmInfo.sshPort! + ' '; + _detectSsh(int.parse(vmInfo.sshPort!)).then((sshRunning) { + if (sshRunning && !sshy) { + setState(() { + _sshVms.add(currentVm); + }); + } else if (!sshRunning && sshy) { + setState(() { + _sshVms.remove(currentVm); + }); + } + }); + } } return [ ListTile( @@ -172,41 +220,24 @@ class _ManagerState extends State with PreferencesMixin { trailing: Row( mainAxisSize: MainAxisSize.min, children: [ - IconButton( - icon: Icon(Icons.monitor, - color: spicy ? Colors.red : null, semanticLabel: spicy ? context.t('Using SPICE display') : context.t('Click to use SPICE display')), - tooltip: spicy ? context.t('Using SPICE display') : context.t('Use SPICE display'), - onPressed: () { - if (spicy) { - setState(() { - _spicyVms.remove(currentVm); - }); - } else { - setState(() { - _spicyVms.add(currentVm); - }); - } - }), IconButton( icon: Icon( active ? Icons.play_arrow : Icons.play_arrow_outlined, - color: active ? Colors.green : null, + color: active ? Colors.green : buttonColor, semanticLabel: active ? 'Running' : 'Run', ), - onPressed: () async { - if (!active) { - Map activeVms = _activeVms; - List args = ['--vm', currentVm + '.conf']; - if (spicy) { - args.addAll(['--display', 'spice']); - } - await Process.start('quickemu', args); - VmInfo info = _parseVmInfo(currentVm); - activeVms[currentVm] = info; - setState(() { - _activeVms = activeVms; - }); + onPressed: active ? null : () async { + Map activeVms = _activeVms; + List args = ['--vm', currentVm + '.conf']; + if (_spicy) { + args.addAll(['--display', 'spice']); } + await Process.start('quickemu', args); + VmInfo info = _parseVmInfo(currentVm); + activeVms[currentVm] = info; + setState(() { + _activeVms = activeVms; + }); }), IconButton( icon: Icon( @@ -214,41 +245,113 @@ class _ManagerState extends State with PreferencesMixin { color: active ? Colors.red : null, semanticLabel: active ? 'Stop' : 'Not running', ), - onPressed: () { - if (active) { - showDialog( - context: context, - builder: (BuildContext context) => AlertDialog( - title: Text(context.t('Stop The Virtual Machine?')), - content: Text('${context.t('You are about to terminate the virtual machine')} $currentVm'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: Text(context.t('Cancel')), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - child: Text(context.t('OK')), - ), - ], - ), - ).then((result) { - result = result ?? false; - if (result) { - Process.run('killall', [currentVm]); - setState(() { - _activeVms.remove(currentVm); - }); - } - }); - } + onPressed: !active ? null : () { + showDialog( + context: context, + builder: (BuildContext context) => AlertDialog( + title: Text(context.t('Stop The Virtual Machine?')), + content: Text('${context.t('You are about to terminate the virtual machine')} $currentVm'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(context.t('Cancel')), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(context.t('OK')), + ), + ], + ), + ).then((result) { + result = result ?? false; + if (result) { + Process.run('killall', [currentVm]); + setState(() { + _activeVms.remove(currentVm); + }); + } + }); }, ), ], )), if (connectInfo.isNotEmpty) ListTile( - title: Text(connectInfo), + title: Text( + connectInfo, + style: TextStyle(fontSize: 12) + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: Icon( + Icons.monitor, + color: _spicy ? buttonColor : null, + semanticLabel: 'Connect display with SPICE', + ), + tooltip: _spicy? 'Connect display with SPICE' : 'SPICE client not found', + onPressed: !_spicy? null : () { + Process.start('spicy', ['-p', vmInfo.spicePort!]); + }, + ), + IconButton( + icon: SvgPicture.asset( + 'assets/images/console.svg', + semanticsLabel: 'Connect with SSH', + color: sshy ? buttonColor : Colors.grey + ), + tooltip: sshy ? 'Connect with SSH' : 'SSH server not detected on guest', + onPressed: !sshy ? null : () { + TextEditingController _usernameController = TextEditingController(); + showDialog( + context: context, + builder: (BuildContext context) => AlertDialog( + title: Text('Launch SSH connection to $currentVm'), + content: TextField( + controller: _usernameController, + decoration: const InputDecoration(hintText: "SSH username"), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Connect'), + ), + ], + ), + ).then((result) { + result = result ?? false; + if (result) { + List sshArgs = ['ssh', '-p', vmInfo.sshPort!, _usernameController.text + '@localhost']; + switch(_terminalEmulator) { + case 'gnome-terminal': + case 'mate-terminal': + sshArgs.insert(0, '--'); + break; + case 'xterm': + case 'konsole': + sshArgs.insert(0, '-e'); + break; + case 'terminator': + case 'xfce4-terminal': + sshArgs.insert(0, '-x'); + break; + case 'guake': + String command = sshArgs.join(' '); + sshArgs = ['-e', command]; + break; + } + Process.start(_terminalEmulator!, sshArgs); + } + }); + }, + ), + ] + ) ), const Divider() ]; diff --git a/pubspec.yaml b/pubspec.yaml index 9a0b676..f0e5aa2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -51,6 +51,7 @@ dependencies: flutter_localizations: sdk: flutter desktop_notifications: ^0.6.1 + flutter_svg: ^0.23.0 dev_dependencies: flutter_test: