diff --git a/data/CMakeLists.txt b/data/CMakeLists.txt index e612a2cce7..0c568c74d2 100644 --- a/data/CMakeLists.txt +++ b/data/CMakeLists.txt @@ -1,8 +1,3 @@ -install( - FILES multipass.gui.autostart.desktop - DESTINATION ${CMAKE_INSTALL_DATADIR}/multipass -) - install( FILES multipass.gui.desktop DESTINATION ${CMAKE_INSTALL_DATADIR}/applications diff --git a/snap-wrappers/bin/client-common.sh b/snap-wrappers/bin/client-common.sh index 69d6abd1e8..b169f0b11f 100644 --- a/snap-wrappers/bin/client-common.sh +++ b/snap-wrappers/bin/client-common.sh @@ -1,15 +1,3 @@ #!/bin/sh export SNAP_REAL_HOME=$( getent passwd $USER | cut -d: -f6 ) - -# so that snapd finds autostart desktop files where it expects them (see https://snapcraft.io/docs/snap-format) -link_autostart() -{ - if [ ! -z "$SNAP_USER_DATA" ] # ensure we're in a proper environment - then - mkdir -p $SNAP_USER_DATA/.config - ln -sfnt $SNAP_USER_DATA/.config ../config/autostart - else - echo "WARNING: SNAP_USER_DATA empty" - fi -} diff --git a/snap-wrappers/bin/launch-multipass b/snap-wrappers/bin/launch-multipass index 431516e91f..ddd35fe939 100755 --- a/snap-wrappers/bin/launch-multipass +++ b/snap-wrappers/bin/launch-multipass @@ -1,6 +1,5 @@ #!/bin/sh . client-common.sh -link_autostart exec "$SNAP/bin/multipass" "$@" diff --git a/snap-wrappers/bin/launch-multipass-gui b/snap-wrappers/bin/launch-multipass-gui deleted file mode 100755 index 86805e1f45..0000000000 --- a/snap-wrappers/bin/launch-multipass-gui +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/sh - -. client-common.sh -link_autostart - -if [ ! -e "${XDG_CONFIG_HOME}/xfce4/terminal/terminalrc" ]; then - mkdir -p "${XDG_CONFIG_HOME}/xfce4/terminal/" - cat < "${XDG_CONFIG_HOME}/xfce4/terminal/terminalrc" -[Configuration] -Encoding=UTF-8 -MiscMenubarDefault=FALSE -ShortcutsNoMenukey=TRUE -EOT -fi - -exec "$SNAP/bin/multipass.gui" "$@" diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 4990c45a13..ae84d7001f 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -86,6 +86,7 @@ apps: - network gui: desktop: usr/share/applications/multipass.gui.desktop + autostart: multipass.gui.autostart.desktop extensions: [ gnome ] environment: &_client-environment XDG_DATA_HOME: $SNAP_USER_DATA diff --git a/src/client/gui/assets/com.canonical.multipass.gui.autostart.plist b/src/client/gui/assets/com.canonical.multipass.gui.autostart.plist new file mode 100644 index 0000000000..0a034b5b01 --- /dev/null +++ b/src/client/gui/assets/com.canonical.multipass.gui.autostart.plist @@ -0,0 +1,26 @@ + + + + + Label + com.canonical.multipass.gui.autostart + + Program + /Applications/Multipass.app/Contents/MacOS/Multipass + + KeepAlive + + SuccessfulExit + + + + RunAtLoad + + + ThrottleInterval + 0 + + ProcessType + Interactive + + diff --git a/data/multipass.gui.autostart.desktop b/src/client/gui/assets/multipass.gui.autostart.desktop similarity index 68% rename from data/multipass.gui.autostart.desktop rename to src/client/gui/assets/multipass.gui.autostart.desktop index 31b0a57d21..86e2f72edd 100644 --- a/data/multipass.gui.autostart.desktop +++ b/src/client/gui/assets/multipass.gui.autostart.desktop @@ -1,6 +1,7 @@ [Desktop Entry] Name=Multipass -Exec=multipass.gui --autostarting +Exec=multipass.gui +Icon=multipass.gui Type=Application Terminal=false Categories=Utility; diff --git a/src/client/gui/lib/catalogue/catalogue.dart b/src/client/gui/lib/catalogue/catalogue.dart index 28931f64e5..c09c2c9dd4 100644 --- a/src/client/gui/lib/catalogue/catalogue.dart +++ b/src/client/gui/lib/catalogue/catalogue.dart @@ -1,20 +1,28 @@ import 'dart:async'; +import 'package:basics/basics.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart' hide ImageInfo; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:grpc/grpc.dart'; import '../providers.dart'; import 'image_card.dart'; import 'launch_panel.dart'; final imagesProvider = FutureProvider>((ref) async { - return ref.watch(daemonAvailableProvider) - ? await ref - .watch(grpcClientProvider) - .find(blueprints: false) - .then((r) => sortImages(r.imagesInfo)) - : ref.state.valueOrNull ?? await Completer>().future; + if (ref.watch(daemonAvailableProvider)) { + final images = ref + .watch(grpcClientProvider) + .find(blueprints: false) + .then((r) => sortImages(r.imagesInfo)); + // artificial delay so that we can see the loading spinner a bit + // otherwise the reply arrives too quickly and we only see a flash of the spinner + await Future.delayed(1.seconds); + return await images; + } + + return ref.state.valueOrNull ?? await Completer>().future; }); // sorts the images in a more user-friendly way @@ -65,32 +73,28 @@ class CatalogueScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final images = ref.watch(imagesProvider).valueOrNull ?? const []; - final imageList = SingleChildScrollView( - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - margin: const EdgeInsets.symmetric(vertical: 10), - child: const Text( - 'Images', - style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), - ), - ), - LayoutBuilder(builder: (_, constraints) { - const minCardWidth = 285; - const spacing = 32.0; - final nCards = constraints.maxWidth ~/ minCardWidth; - final whiteSpace = spacing * (nCards - 1); - final cardWidth = (constraints.maxWidth - whiteSpace) / nCards; - return Wrap( - runSpacing: spacing, - spacing: spacing, - children: - images.map((image) => ImageCard(image, cardWidth)).toList(), - ); - }), - const SizedBox(height: 32), - ]), - ); + final content = ref.watch(imagesProvider).when( + skipLoadingOnRefresh: false, + data: _buildCatalogue, + error: (error, _) { + final errorMessage = error is GrpcError ? error.message : error; + return Center( + child: Column(children: [ + const SizedBox(height: 32), + Text( + 'Failed to retrieve images: $errorMessage', + style: const TextStyle(fontSize: 16), + ), + const SizedBox(height: 16), + TextButton( + onPressed: () => ref.invalidate(imagesProvider), + child: const Text('Refresh'), + ), + ]), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + ); final welcomeText = Container( constraints: const BoxConstraints(maxWidth: 500), @@ -118,10 +122,38 @@ class CatalogueScreen extends ConsumerWidget { children: [ welcomeText, const Divider(), - Expanded(child: imageList), + Expanded(child: content), ], ), ), ); } + + Widget _buildCatalogue(List images) { + return SingleChildScrollView( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Container( + margin: const EdgeInsets.symmetric(vertical: 10), + child: const Text( + 'Images', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + ), + LayoutBuilder(builder: (_, constraints) { + const minCardWidth = 285; + const spacing = 32.0; + final nCards = constraints.maxWidth ~/ minCardWidth; + final whiteSpace = spacing * (nCards - 1); + final cardWidth = (constraints.maxWidth - whiteSpace) / nCards; + return Wrap( + runSpacing: spacing, + spacing: spacing, + children: + images.map((image) => ImageCard(image, cardWidth)).toList(), + ); + }), + const SizedBox(height: 32), + ]), + ); + } } diff --git a/src/client/gui/lib/catalogue/image_card.dart b/src/client/gui/lib/catalogue/image_card.dart index 681e0b75b0..30a5b2a1b5 100644 --- a/src/client/gui/lib/catalogue/image_card.dart +++ b/src/client/gui/lib/catalogue/image_card.dart @@ -56,42 +56,45 @@ class ImageCard extends ConsumerWidget { child: Text(image.codename), ), ), - Row( - children: [ - OutlinedButton( - onPressed: () { - final grpcClient = ref.read(grpcClientProvider); - final name = ref.read(randomNameProvider); - final request = LaunchRequest(instanceName: name); - final aliasInfo = image.aliasesInfo.first; - request.image = aliasInfo.alias; - if (aliasInfo.hasRemoteName()) { - request.remoteName = aliasInfo.remoteName; - } + Row(children: [ + OutlinedButton( + onPressed: () { + final name = ref.read(randomNameProvider); + final aliasInfo = image.aliasesInfo.first; + final request = LaunchRequest( + instanceName: name, + image: aliasInfo.alias, + remoteName: + aliasInfo.hasRemoteName() ? aliasInfo.remoteName : null, + ); - final stream = grpcClient.launch(request); - ref.read(launchOperationProvider.notifier).state = - (stream, name, imageName(image)); - Scaffold.of(context).openEndDrawer(); - }, - child: const Text('Launch'), - ), - const SizedBox(width: 16), - OutlinedButton( - onPressed: () { - ref.read(launchingImageProvider.notifier).state = image; - Scaffold.of(context).openEndDrawer(); - }, - child: SvgPicture.asset( - 'assets/settings.svg', - colorFilter: const ColorFilter.mode( - Colors.black, - BlendMode.srcIn, - ), + final grpcClient = ref.read(grpcClientProvider); + final operation = LaunchOperation( + stream: grpcClient.launch(request), + name: name, + image: imageName(image), + ); + + ref.read(launchOperationProvider.notifier).state = operation; + Scaffold.of(context).openEndDrawer(); + }, + child: const Text('Launch'), + ), + const SizedBox(width: 16), + OutlinedButton( + onPressed: () { + ref.read(launchingImageProvider.notifier).state = image; + Scaffold.of(context).openEndDrawer(); + }, + child: SvgPicture.asset( + 'assets/settings.svg', + colorFilter: const ColorFilter.mode( + Colors.black, + BlendMode.srcIn, ), ), - ], - ), + ), + ]), ], ), ), diff --git a/src/client/gui/lib/catalogue/launch_form.dart b/src/client/gui/lib/catalogue/launch_form.dart index 35a1e894a6..5be242e3ff 100644 --- a/src/client/gui/lib/catalogue/launch_form.dart +++ b/src/client/gui/lib/catalogue/launch_form.dart @@ -121,9 +121,12 @@ class LaunchForm extends ConsumerWidget { } final grpcClient = ref.read(grpcClientProvider); - final stream = grpcClient.launch(launchRequest, mountRequests); - ref.read(launchOperationProvider.notifier).state = - (stream, launchRequest.instanceName, imageName(imageInfo)); + final operation = LaunchOperation( + stream: grpcClient.launch(launchRequest, mountRequests), + name: launchRequest.instanceName, + image: imageName(imageInfo), + ); + ref.read(launchOperationProvider.notifier).state = operation; }, child: const Text('Launch'), ); @@ -156,7 +159,10 @@ class LaunchForm extends ConsumerWidget { const SizedBox(height: 40), bridgedSwitch, const SizedBox(height: 40), - MountPointList(onSaved: (requests) => mountRequests.addAll(requests)), + MountPointList( + width: 300, + onSaved: (requests) => mountRequests.addAll(requests), + ), const SizedBox(height: 40), Row(children: [ launchButton, diff --git a/src/client/gui/lib/catalogue/launch_operation_progress.dart b/src/client/gui/lib/catalogue/launch_operation_progress.dart index 92a22d2483..75825bf818 100644 --- a/src/client/gui/lib/catalogue/launch_operation_progress.dart +++ b/src/client/gui/lib/catalogue/launch_operation_progress.dart @@ -30,15 +30,13 @@ class LaunchOperationProgress extends ConsumerWidget { padding: const EdgeInsets.all(16), alignment: Alignment.topLeft, child: StreamBuilder( - stream: stream.doOnData((r) { - if (r == null) { - Scaffold.of(context).closeEndDrawer(); - ref.read(sidebarKeyProvider.notifier).state = 'vm-$name'; - Timer( - const Duration(milliseconds: 200), - () => ref.invalidate(launchOperationProvider), - ); - } + stream: stream.doOnData((reply) { + if (reply != null) return; + Scaffold.of(context).closeEndDrawer(); + Timer(250.milliseconds, () { + ref.invalidate(launchOperationProvider); + ref.read(sidebarKeyProvider.notifier).set('vm-$name'); + }); }), builder: (_, snapshot) { if (snapshot.hasError) { @@ -55,10 +53,9 @@ class LaunchOperationProgress extends ConsumerWidget { icon: const Icon(Icons.close), onPressed: () { Scaffold.of(context).closeEndDrawer(); - Timer( - const Duration(milliseconds: 200), - () => ref.invalidate(launchOperationProvider), - ); + Timer(250.milliseconds, () { + ref.invalidate(launchOperationProvider); + }); }, ), ), diff --git a/src/client/gui/lib/catalogue/launch_panel.dart b/src/client/gui/lib/catalogue/launch_panel.dart index 0814c8f5e6..88f5d9513b 100644 --- a/src/client/gui/lib/catalogue/launch_panel.dart +++ b/src/client/gui/lib/catalogue/launch_panel.dart @@ -6,9 +6,19 @@ import '../providers.dart'; import 'launch_form.dart'; import 'launch_operation_progress.dart'; -final launchOperationProvider = - StateProvider<(Stream?>, String, String)?>( - (_) => null); +class LaunchOperation { + final Stream?> stream; + final String name; + final String image; + + LaunchOperation({ + required this.stream, + required this.name, + required this.image, + }); +} + +final launchOperationProvider = StateProvider((_) => null); class LaunchPanel extends ConsumerWidget { const LaunchPanel({super.key}); @@ -20,7 +30,7 @@ class LaunchPanel extends ConsumerWidget { return LaunchForm(); } - final (stream, name, image) = values; + final LaunchOperation(:stream, :name, :image) = values; return FocusableActionDetector( autofocus: true, actions: { diff --git a/src/client/gui/lib/ffi.dart b/src/client/gui/lib/ffi.dart index f06f4b1fb2..7f5418886f 100644 --- a/src/client/gui/lib/ffi.dart +++ b/src/client/gui/lib/ffi.dart @@ -4,12 +4,7 @@ import 'dart:io'; import 'package:ffi/ffi.dart'; -String _libraryName(String baseName) { - if (Platform.isLinux) return 'lib$baseName.so'; - if (Platform.isWindows) return '$baseName.dll'; - if (Platform.isMacOS) return 'lib$baseName.dylib'; - throw const OSError('OS not supported'); -} +import 'platform/platform.dart'; extension on ffi.Pointer { String get string { @@ -22,7 +17,7 @@ extension on ffi.Pointer { } } -final _lib = ffi.DynamicLibrary.open(_libraryName('dart_ffi')); +final _lib = ffi.DynamicLibrary.open(mpPlatform.ffiLibraryName); final _multipassVersion = _lib.lookupFunction Function(), ffi.Pointer Function()>('multipass_version'); @@ -71,7 +66,7 @@ final _setSetting = _lib.lookupFunction< final uid = _lib.lookupFunction('uid'); final gid = _lib.lookupFunction('gid'); -final default_id = +final defaultId = _lib.lookupFunction('default_id'); final _memoryInBytes = _lib.lookupFunction< diff --git a/src/client/gui/lib/grpc_client.dart b/src/client/gui/lib/grpc_client.dart index 93e8c7a73f..81ab2f1c6d 100644 --- a/src/client/gui/lib/grpc_client.dart +++ b/src/client/gui/lib/grpc_client.dart @@ -14,31 +14,36 @@ export 'generated/multipass.pbgrpc.dart'; typedef Status = InstanceStatus_Status; typedef VmInfo = DetailedInfoItem; typedef ImageInfo = FindReply_ImageInfo; +typedef MountPaths = MountInfo_MountPaths; typedef RpcMessage = GeneratedMessage; extension on RpcMessage { String get repr => '$runtimeType${toProto3Json()}'; } -void Function(Notification) logGrpc(RpcMessage request) { +void Function(StreamNotification) logGrpc(RpcMessage request) { return (notification) { switch (notification.kind) { - case Kind.onData: - final reply = notification.requireData.deepCopy(); + case NotificationKind.data: + final reply = notification.requireDataValue.deepCopy(); if (reply is SSHInfoReply) { for (final info in reply.sshInfo.values) { info.privKeyBase64 = '*hidden*'; } } + if (reply is LaunchReply) { + final percent = reply.launchProgress.percentComplete; + if (!['0', '100', '-1'].contains(percent)) return; + } logger.i('${request.repr} received ${reply.repr}'); - case Kind.onError: - final es = notification.errorAndStackTrace; + case NotificationKind.error: + final es = notification.errorAndStackTraceOrNull; logger.e( '${request.repr} received an error', error: es?.error, stackTrace: es?.stackTrace, ); - case Kind.onDone: + case NotificationKind.done: logger.i('${request.repr} is done'); } }; @@ -174,9 +179,9 @@ class GrpcClient { .firstOrNull; } - Future umount(String name) { + Future umount(String name, [String? path]) { final request = UmountRequest( - targetPaths: [TargetPathInfo(instanceName: name)], + targetPaths: [TargetPathInfo(instanceName: name, targetPath: path)], ); logger.i('Sent ${request.repr}'); return _client @@ -259,13 +264,12 @@ class CustomChannelCredentials extends ChannelCredentials { final List certificateKey; CustomChannelCredentials({ + super.authority, required List certificate, required this.certificateKey, - String? authority, }) : certificateChain = certificate, super.secure( certificates: certificate, - authority: authority, onBadCertificate: allowBadCertificates, ); diff --git a/src/client/gui/lib/logger.dart b/src/client/gui/lib/logger.dart index dca05bf660..4c7698a3dc 100644 --- a/src/client/gui/lib/logger.dart +++ b/src/client/gui/lib/logger.dart @@ -1,4 +1,6 @@ +import 'dart:convert'; import 'dart:io'; +import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:logger/logger.dart'; @@ -16,17 +18,7 @@ Future setupLogger() async { logger = Logger( filter: NoFilter(), - printer: PrettyPrinter( - colors: false, - lineLength: 20, - printTime: true, - methodCount: 0, - levelEmojis: { - Level.debug: 'DEBUG', - Level.error: 'ERROR', - Level.info: 'INFO', - Level.warning: 'WARN', - }, + printer: MpPrettyPrinter( excludePaths: [ 'dart:', 'package:flutter', @@ -54,3 +46,125 @@ Future setupLogger() async { return true; }; } + +class MpPrettyPrinter extends LogPrinter { + static final _deviceStackTraceRegex = RegExp(r'#[0-9]+\s+(.+) \((\S+)\)'); + static final _webStackTraceRegex = RegExp(r'^((packages|dart-sdk)/\S+/)'); + static final _browserStackTraceRegex = + RegExp(r'^(?:package:)?(dart:\S+|\S+)'); + final int stackTraceBeginIndex; + final int? methodCount; + final int? errorMethodCount; + final List excludePaths; + + MpPrettyPrinter({ + this.stackTraceBeginIndex = 0, + this.methodCount = 0, + this.errorMethodCount = 8, + this.excludePaths = const [], + }); + + @override + List log(final LogEvent event) { + String? stackTraceStr; + if (event.error != null) { + if ((errorMethodCount == null || errorMethodCount! > 0)) { + stackTraceStr = formatStackTrace( + event.stackTrace ?? StackTrace.current, + errorMethodCount, + ); + } + } else if (methodCount == null || methodCount! > 0) { + stackTraceStr = formatStackTrace( + event.stackTrace ?? StackTrace.current, + methodCount, + ); + } + + return _formatAndPrint( + event.level, + stringifyMessage(event.message), + event.time.toString(), + event.error?.toString(), + stackTraceStr, + ); + } + + String? formatStackTrace(StackTrace? stackTrace, int? methodCount) { + final lines = stackTrace.toString().split('\n').where((line) { + return !_discardDeviceStacktraceLine(line) && + !_discardWebStacktraceLine(line) && + !_discardBrowserStacktraceLine(line) && + line.isNotEmpty; + }).toList(); + + final formatted = []; + final stackTraceLength = + methodCount != null ? min(lines.length, methodCount) : lines.length; + + for (var count = 0; count < stackTraceLength; count++) { + final line = lines[count]; + if (count < stackTraceBeginIndex) continue; + formatted.add('#$count ${line.replaceFirst(RegExp(r'#\d+\s+'), '')}'); + } + + return formatted.isEmpty ? null : formatted.join('\n'); + } + + bool _isInExcludePaths(final String segment) { + return excludePaths.any(segment.startsWith); + } + + bool _discardDeviceStacktraceLine(String line) { + final match = _deviceStackTraceRegex.matchAsPrefix(line); + if (match == null) return false; + final segment = match.group(2)!; + if (segment.startsWith('package:logger')) return true; + return _isInExcludePaths(segment); + } + + bool _discardWebStacktraceLine(String line) { + final match = _webStackTraceRegex.matchAsPrefix(line); + if (match == null) return false; + final segment = match.group(1)!; + if (segment.startsWith('packages/logger') || + segment.startsWith('dart-sdk/lib')) return true; + return _isInExcludePaths(segment); + } + + bool _discardBrowserStacktraceLine(String line) { + final match = _browserStackTraceRegex.matchAsPrefix(line); + if (match == null) return false; + final segment = match.group(1)!; + if (segment.startsWith('package:logger') || segment.startsWith('dart:')) { + return true; + } + return _isInExcludePaths(segment); + } + + Object toEncodableFallback(dynamic object) { + return object.toString(); + } + + String stringifyMessage(dynamic message) { + final finalMessage = message is Function ? message() : message; + final encoder = JsonEncoder.withIndent(' ', toEncodableFallback); + return finalMessage is Map || finalMessage is Iterable + ? encoder.convert(finalMessage) + : finalMessage.toString(); + } + + List _formatAndPrint( + Level level, + String message, + String time, + String? error, + String? stacktrace, + ) { + return [ + '$time ${level.name.toUpperCase()} $message', + if (error != null) '\t${error.replaceAll('\n', '\n\t')}', + if (stacktrace != null) '\t${stacktrace.replaceAll('\n', '\n\t')}', + ]; + } +} diff --git a/src/client/gui/lib/main.dart b/src/client/gui/lib/main.dart index c4f15986f1..da5807d68b 100644 --- a/src/client/gui/lib/main.dart +++ b/src/client/gui/lib/main.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hotkey_manager/hotkey_manager.dart'; @@ -13,6 +11,7 @@ import 'help.dart'; import 'logger.dart'; import 'notifications.dart'; import 'providers.dart'; +import 'settings/hotkey.dart'; import 'settings/settings.dart'; import 'sidebar.dart'; import 'tray_menu.dart'; @@ -26,8 +25,9 @@ void main() async { await windowManager.ensureInitialized(); const windowOptions = WindowOptions( + center: true, minimumSize: Size(1000, 600), - size: Size(1400, 800), + size: Size(1400, 822), title: 'Multipass', ); await windowManager.waitUntilReadyToShow(windowOptions, () async { @@ -80,9 +80,13 @@ class _AppState extends ConsumerState with WindowListener { children: widgets.entries.map((e) { final MapEntry(:key, value: widget) = e; final isCurrent = key == currentKey; + var maintainState = key != SettingsScreen.sidebarKey; + if (key.startsWith('vm-')) { + maintainState = ref.read(vmVisitedProvider(key)); + } return Visibility( key: Key(key), - maintainState: key != SettingsScreen.sidebarKey, + maintainState: maintainState, visible: isCurrent, child: FocusScope( autofocus: isCurrent, @@ -94,6 +98,8 @@ class _AppState extends ConsumerState with WindowListener { }).toList(), ); + final hotkey = ref.watch(hotkeyProvider); + return Stack(children: [ AnimatedPositioned( duration: SideBar.animationDuration, @@ -105,7 +111,11 @@ class _AppState extends ConsumerState with WindowListener { : SideBar.collapsedWidth, child: content, ), - const SideBar(), + CallbackGlobalShortcuts( + key: hotkey != null ? GlobalObjectKey(hotkey) : null, + bindings: {if (hotkey != null) hotkey: goToPrimary}, + child: const SideBar(), + ), const Align( alignment: Alignment.bottomRight, child: SizedBox(width: 300, child: NotificationList()), @@ -114,6 +124,15 @@ class _AppState extends ConsumerState with WindowListener { ]); } + void goToPrimary() { + final vms = ref.read(vmNamesProvider); + final primary = ref.read(clientSettingProvider(primaryNameKey)); + if (vms.contains(primary)) { + ref.read(sidebarKeyProvider.notifier).set('vm-$primary'); + windowManager.show(); + } + } + @override void initState() { super.initState(); @@ -128,26 +147,29 @@ class _AppState extends ConsumerState with WindowListener { } @override - void onWindowClose() { + void onWindowClose() async { + if (!await windowManager.isPreventClose()) return; final daemonAvailable = ref.read(daemonAvailableProvider); final vmsRunning = ref.read(vmStatusesProvider).values.contains(Status.RUNNING); - if (!daemonAvailable || !vmsRunning) exit(0); + if (!daemonAvailable || !vmsRunning) windowManager.destroy(); stopAllInstances() { - final notification = OperationNotification( - text: 'Stopping all instances', - future: ref.read(grpcClientProvider).stop([]).then((_) { + final notificationsNotifier = ref.read(notificationsProvider.notifier); + notificationsNotifier.addOperation( + ref.read(grpcClientProvider).stop([]), + loading: 'Stopping all instances', + onError: (error) => 'Failed to stop all instances: $error', + onSuccess: (_) { windowManager.destroy(); return 'Stopped all instances'; - }).onError((_, __) => throw 'Failed to stop all instances'), + }, ); - ref.read(notificationsProvider.notifier).add(notification); } switch (ref.read(guiSettingProvider(onAppCloseKey))) { case 'nothing': - exit(0); + windowManager.destroy(); case 'stop': stopAllInstances(); default: @@ -196,11 +218,7 @@ final theme = ThemeData( borderRadius: BorderRadius.circular(2), ), side: const BorderSide(color: Color(0xff333333)), - textStyle: const TextStyle( - fontFamily: 'Ubuntu', - fontSize: 16, - fontWeight: FontWeight.w300, - ), + textStyle: const TextStyle(fontFamily: 'Ubuntu', fontSize: 16), ), ), scaffoldBackgroundColor: Colors.white, @@ -213,11 +231,7 @@ final theme = ThemeData( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(2), ), - textStyle: const TextStyle( - fontFamily: 'Ubuntu', - fontSize: 16, - fontWeight: FontWeight.w300, - ), + textStyle: const TextStyle(fontFamily: 'Ubuntu', fontSize: 16), ), ), textSelectionTheme: const TextSelectionThemeData( diff --git a/src/client/gui/lib/notifications/notifications_list.dart b/src/client/gui/lib/notifications/notifications_list.dart index 89854db64b..03a612204b 100644 --- a/src/client/gui/lib/notifications/notifications_list.dart +++ b/src/client/gui/lib/notifications/notifications_list.dart @@ -16,10 +16,11 @@ class _NotificationListState extends ConsumerState { final activeNotifications = []; void updateState(BuiltList notifications) { - if (notifications.length > activeNotifications.length) { - listKey.currentState?.insertItem(activeNotifications.length); - activeNotifications.add(notifications[activeNotifications.length]); + for (var i = activeNotifications.length; i < notifications.length; i++) { + listKey.currentState?.insertItem(i); + activeNotifications.add(notifications[i]); } + for (var i = activeNotifications.length - 1; i >= 0; i--) { if (notifications.contains(activeNotifications[i])) continue; final notification = activeNotifications.removeAt(i); @@ -84,14 +85,20 @@ class NotificationTile extends ConsumerWidget { onInvoke: (_) => removeSelf(ref), ), }, - child: Container( - margin: const EdgeInsets.all(5), - height: 70, - decoration: const BoxDecoration( - color: Colors.white, - boxShadow: [BoxShadow(blurRadius: 5, color: Colors.black38)], + child: IntrinsicHeight( + child: Container( + margin: const EdgeInsets.all(5), + constraints: const BoxConstraints(minHeight: 60), + decoration: const BoxDecoration( + color: Colors.white, + boxShadow: [BoxShadow(blurRadius: 5, color: Colors.black38)], + ), + child: DefaultTextStyle.merge( + maxLines: 10, + overflow: TextOverflow.ellipsis, + child: notification, + ), ), - child: notification, ), ); } diff --git a/src/client/gui/lib/notifications/notifications_provider.dart b/src/client/gui/lib/notifications/notifications_provider.dart index 181ce64ba9..0b8c01d9be 100644 --- a/src/client/gui/lib/notifications/notifications_provider.dart +++ b/src/client/gui/lib/notifications/notifications_provider.dart @@ -1,8 +1,15 @@ +import 'dart:async'; + import 'package:built_collection/built_collection.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:grpc/grpc.dart'; + +import '../notifications.dart'; -class NotificationsNotifier extends Notifier> { +String objectToString(Object? object) => object.toString(); + +class NotificationsNotifier extends AutoDisposeNotifier> { @override BuiltList build() => BuiltList(); @@ -13,9 +20,52 @@ class NotificationsNotifier extends Notifier> { void remove(Widget notification) { state = state.rebuild((builder) => builder.remove(notification)); } + + void addError( + Object? error, [ + String Function(Object?) format = objectToString, + ]) { + if (error is GrpcError) error = error.message; + add(ErrorNotification(text: format(error))); + } + + void addOperation( + Future op, { + required String loading, + required String Function(T) onSuccess, + required String Function(Object?) onError, + }) { + add(OperationNotification( + text: loading, + future: op.then(onSuccess).onError((error, _) { + if (error is GrpcError) error = error.message; + throw onError(error); + }), + )); + } } final notificationsProvider = - NotifierProvider>( + NotifierProvider.autoDispose>( NotificationsNotifier.new, ); + +extension ErrorNotificationWidgetRefExtension on WidgetRef { + void Function(Object?, StackTrace) notifyError( + String Function(Object?) onError, + ) { + return (error, _) { + read(notificationsProvider.notifier).addError(error, onError); + }; + } +} + +extension ErrorNotificationRefExtension on Ref { + void Function(Object?, StackTrace) notifyError( + String Function(Object?) onError, + ) { + return (error, _) { + read(notificationsProvider.notifier).addError(error, onError); + }; + } +} diff --git a/src/client/gui/lib/platform/linux.dart b/src/client/gui/lib/platform/linux.dart new file mode 100644 index 0000000000..c0238f0565 --- /dev/null +++ b/src/client/gui/lib/platform/linux.dart @@ -0,0 +1,67 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import '../settings/autostart_notifiers.dart'; +import 'platform.dart'; + +class LinuxPlatform extends MpPlatform { + @override + AutostartNotifier autostartNotifier() => LinuxAutostartNotifier(); + + @override + Map get drivers => const { + 'qemu': 'QEMU', + 'lxd': 'LXD', + 'libvirt': 'libvirt', + }; + + @override + String get ffiLibraryName => 'libdart_ffi.so'; + + @override + bool get showToggleWindow => true; + + @override + Map get terminalShortcuts => const { + SingleActivator(LogicalKeyboardKey.keyC, control: true, shift: true): + CopySelectionTextIntent.copy, + SingleActivator(LogicalKeyboardKey.keyV, control: true, shift: true): + PasteTextIntent(SelectionChangedCause.keyboard), + }; + + @override + String get trayIconFile => 'icon.png'; + + @override + String get metaKey => 'Super'; +} + +class LinuxAutostartNotifier extends AutostartNotifier { + static const autostartFile = 'multipass.gui.autostart.desktop'; + final file = File( + '${Platform.environment['HOME']}/.config/autostart/$autostartFile', + ); + + + LinuxAutostartNotifier() { + if (FileSystemEntity.isLinkSync(file.path)) { + Link(file.path).deleteSync(); + } + } + + @override + Future build() => file.exists(); + + @override + Future doSet(bool value) async { + if (value) { + final data = await rootBundle.load('assets/$autostartFile'); + await file.parent.create(); + await file.writeAsBytes(data.buffer.asUint8List()); + } else { + if (await file.exists()) await file.delete(); + } + } +} diff --git a/src/client/gui/lib/platform/macos.dart b/src/client/gui/lib/platform/macos.dart new file mode 100644 index 0000000000..31b9af9e0d --- /dev/null +++ b/src/client/gui/lib/platform/macos.dart @@ -0,0 +1,61 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import '../settings/autostart_notifiers.dart'; +import 'platform.dart'; + +class MacOSPlatform extends MpPlatform { + @override + AutostartNotifier autostartNotifier() => MacOSAutostartNotifier(); + + @override + Map get drivers => const { + 'qemu': 'QEMU', + 'virtualbox': 'VirtualBox', + }; + + @override + String get ffiLibraryName => 'libdart_ffi.dylib'; + + @override + bool get showToggleWindow => false; + + @override + Map get terminalShortcuts => const { + SingleActivator(LogicalKeyboardKey.keyC, meta: true): + CopySelectionTextIntent.copy, + SingleActivator(LogicalKeyboardKey.keyV, meta: true): + PasteTextIntent(SelectionChangedCause.keyboard), + }; + + @override + String get trayIconFile => 'icon_template.png'; + + @override + String get metaKey => 'Command'; + + @override + String get altKey => 'Option'; +} + +class MacOSAutostartNotifier extends AutostartNotifier { + static const plistFile = 'com.canonical.multipass.gui.autostart.plist'; + final file = File( + '${Platform.environment['HOME']}/Library/LaunchAgents/$plistFile', + ); + + @override + Future build() => file.exists(); + + @override + Future doSet(bool value) async { + if (value) { + final data = await rootBundle.load('assets/$plistFile'); + await file.writeAsBytes(data.buffer.asUint8List()); + } else { + if (await file.exists()) await file.delete(); + } + } +} diff --git a/src/client/gui/lib/platform/platform.dart b/src/client/gui/lib/platform/platform.dart new file mode 100644 index 0000000000..81ff8ff608 --- /dev/null +++ b/src/client/gui/lib/platform/platform.dart @@ -0,0 +1,35 @@ +import 'dart:io'; + +import 'package:flutter/widgets.dart'; + +import '../settings/autostart_notifiers.dart'; +import 'linux.dart'; +import 'macos.dart'; +import 'windows.dart'; + +abstract class MpPlatform { + String get ffiLibraryName; + + AutostartNotifier autostartNotifier(); + + Map get drivers; + + String get trayIconFile; + + Map get terminalShortcuts; + + bool get showToggleWindow; + + String get altKey => 'Alt'; + + String get metaKey => 'Meta'; +} + +MpPlatform _getPlatform() { + if (Platform.isLinux) return LinuxPlatform(); + if (Platform.isMacOS) return MacOSPlatform(); + if (Platform.isWindows) return WindowsPlatform(); + throw UnimplementedError('Platform not supported'); +} + +final mpPlatform = _getPlatform(); diff --git a/src/client/gui/lib/platform/windows.dart b/src/client/gui/lib/platform/windows.dart new file mode 100644 index 0000000000..eaea1a00e0 --- /dev/null +++ b/src/client/gui/lib/platform/windows.dart @@ -0,0 +1,77 @@ +import 'dart:ffi'; +import 'dart:io'; + +import 'package:ffi/ffi.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:win32/win32.dart'; + +import '../settings/autostart_notifiers.dart'; +import 'platform.dart'; + +class WindowsPlatform extends MpPlatform { + @override + AutostartNotifier autostartNotifier() => WindowsAutostartNotifier(); + + @override + Map get drivers => const { + 'hyperv': 'Hyper-V', + 'virtualbox': 'VirtualBox', + }; + + @override + String get ffiLibraryName => 'dart_ffi.dll'; + + @override + bool get showToggleWindow => true; + + @override + Map get terminalShortcuts => const { + SingleActivator(LogicalKeyboardKey.keyC, control: true, shift: true): + CopySelectionTextIntent.copy, + SingleActivator(LogicalKeyboardKey.keyV, control: true, shift: true): + PasteTextIntent(SelectionChangedCause.keyboard), + }; + + @override + String get trayIconFile => 'icon.ico'; + + @override + String get metaKey => 'Win'; +} + +class WindowsAutostartNotifier extends AutostartNotifier { + WindowsAutostartNotifier() { + CoInitializeEx(nullptr, 2); + } + + final link = File( + '${Platform.environment['AppData']}/Microsoft/Windows/Start Menu/Programs/Startup/Multipass.lnk', + ); + + @override + Future build() => link.exists(); + + @override + Future doSet(bool value) async { + if (value) { + _createShortcut(Platform.resolvedExecutable, link.path); + } else { + if (await link.exists()) await link.delete(); + } + } + + void _createShortcut(String path, String linkPath) { + final shellLink = ShellLink.createInstance(); + final pathUtf16 = path.toNativeUtf16(); + final linkPathUtf16 = linkPath.toNativeUtf16(); + + try { + shellLink.setPath(pathUtf16); + IPersistFile.from(shellLink).save(linkPathUtf16, TRUE); + } finally { + free(pathUtf16); + free(linkPathUtf16); + } + } +} diff --git a/src/client/gui/lib/providers.dart b/src/client/gui/lib/providers.dart index 44a180b070..23f8fe0817 100644 --- a/src/client/gui/lib/providers.dart +++ b/src/client/gui/lib/providers.dart @@ -39,7 +39,7 @@ final vmInfosStreamProvider = StreamProvider>((ref) async* { // this is to de-duplicate errors received from the stream Object? lastError; while (true) { - final timer = Future.delayed(900.milliseconds); + final timer = Future.delayed(1900.milliseconds); try { final infos = await grpcClient.info(); yield infos @@ -53,11 +53,9 @@ final vmInfosStreamProvider = StreamProvider>((ref) async* { } lastError = error; } + // these two timers make it so that requests are sent with at least a 2s pause between them + // but if the request takes longer than 1.9s to complete, we still wait 100ms before sending the next one await timer; - // this is so that if the request takes less than 900 milliseconds, it waits the rest of the 900 that remain plus 100 milliseconds - // this is so that there is 1 second pause between requests that take less than 1 second - // but if the timer is done before the request completes, it still waits at least 100 milliseconds before the next request - // so that it does not spam the daemon if requests take longer to complete await Future.delayed(100.milliseconds); } }); diff --git a/src/client/gui/lib/settings/autostart_notifiers.dart b/src/client/gui/lib/settings/autostart_notifiers.dart new file mode 100644 index 0000000000..3de4a786c0 --- /dev/null +++ b/src/client/gui/lib/settings/autostart_notifiers.dart @@ -0,0 +1,21 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../platform/platform.dart'; + +final autostartProvider = + AsyncNotifierProvider.autoDispose( + mpPlatform.autostartNotifier); + +abstract class AutostartNotifier extends AutoDisposeAsyncNotifier { + Future set(bool value) async { + try { + await doSet(value); + } finally { + ref.invalidateSelf(); + } + } + + Future doSet(bool value); +} diff --git a/src/client/gui/lib/settings/general_settings.dart b/src/client/gui/lib/settings/general_settings.dart index 8eb19e7c47..e5e0b81310 100644 --- a/src/client/gui/lib/settings/general_settings.dart +++ b/src/client/gui/lib/settings/general_settings.dart @@ -5,8 +5,10 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:url_launcher/url_launcher.dart'; import '../dropdown.dart'; +import '../notifications/notifications_provider.dart'; import '../providers.dart'; import '../switch.dart'; +import 'autostart_notifiers.dart'; final updateProvider = Provider.autoDispose((ref) { ref @@ -25,6 +27,7 @@ class GeneralSettings extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final update = ref.watch(updateProvider); + final autostart = ref.watch(autostartProvider).valueOrNull ?? false; final onAppClose = ref.watch(onAppCloseProvider); return Column( @@ -38,10 +41,15 @@ class GeneralSettings extends ConsumerWidget { if (update.version.isNotBlank) UpdateAvailable(update), Switch( label: 'Open the Multipass GUI on startup', - value: false, + value: autostart, trailingSwitch: true, size: 30, - onChanged: (value) {}, + onChanged: (value) { + ref + .read(autostartProvider.notifier) + .set(value) + .onError(ref.notifyError((e) => 'Failed to set autostart: $e')); + }, ), const SizedBox(height: 20), Dropdown( diff --git a/src/client/gui/lib/settings/hotkey.dart b/src/client/gui/lib/settings/hotkey.dart new file mode 100644 index 0000000000..9a6d1698c7 --- /dev/null +++ b/src/client/gui/lib/settings/hotkey.dart @@ -0,0 +1,199 @@ +import 'package:basics/basics.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../platform/platform.dart'; +import '../providers.dart'; + +final hotkeySettingProvider = guiSettingProvider(hotkeyKey); + +class HotkeyNotifier extends Notifier { + @override + SingleActivator? build() { + final hotkeyString = ref.read(hotkeySettingProvider); + if (hotkeyString == null) return null; + final components = hotkeyString.toLowerCase().split('+'); + final keyId = + components.map(int.tryParse).firstWhereOrNull((e) => e != null); + if (keyId == null) return null; + final key = LogicalKeyboardKey.findKeyByKeyId(keyId); + if (key == null) return null; + if (!components.containsAny(['alt', 'control', 'meta'])) return null; + + return SingleActivator( + key, + alt: components.contains('alt'), + control: components.contains('control'), + meta: components.contains('meta'), + shift: components.contains('shift'), + includeRepeats: false, + ); + } + + void set(SingleActivator? activator) { + state = activator; + if (activator == null) { + ref.read(hotkeySettingProvider.notifier).set(''); + return; + } + + final hotkeyString = [ + if (activator.alt) 'alt', + if (activator.control) 'control', + if (activator.meta) 'meta', + if (activator.shift) 'shift', + activator.trigger.keyId.toString(), + ].join('+'); + ref.read(hotkeySettingProvider.notifier).set(hotkeyString); + } +} + +final hotkeyProvider = + NotifierProvider(HotkeyNotifier.new); + +class HotkeyRecorder extends StatefulWidget { + final SingleActivator? value; + final ValueChanged? onSave; + + const HotkeyRecorder({super.key, this.value, this.onSave}); + + @override + State createState() => HotkeyRecorderState(); +} + +class HotkeyRecorderState extends State { + final focusNode = FocusNode(); + late var hasFocus = focusNode.hasFocus; + var alt = false; + var control = false; + var meta = false; + var shift = false; + LogicalKeyboardKey? key; + var shouldSave = false; + + @override + void initState() { + super.initState(); + set(widget.value); + } + + @override + void dispose() { + focusNode.dispose(); + super.dispose(); + } + + void set(SingleActivator? value) { + alt = value?.alt ?? false; + control = value?.control ?? false; + meta = value?.meta ?? false; + shift = value?.shift ?? false; + key = value?.trigger; + } + + void setAlt(bool pressed) => alt = pressed; + + void setCtrl(bool pressed) => control = pressed; + + void setMeta(bool pressed) => meta = pressed; + + void setShift(bool pressed) => shift = pressed; + + late final modifierSetters = { + LogicalKeyboardKey.altLeft: setAlt, + LogicalKeyboardKey.altRight: setAlt, + LogicalKeyboardKey.controlLeft: setCtrl, + LogicalKeyboardKey.controlRight: setCtrl, + LogicalKeyboardKey.metaLeft: setMeta, + LogicalKeyboardKey.metaRight: setMeta, + LogicalKeyboardKey.superKey: setMeta, + LogicalKeyboardKey.shiftLeft: setShift, + LogicalKeyboardKey.shiftRight: setShift, + }; + + KeyEventResult handleKeyEvent(_, KeyEvent event) { + if (event is KeyRepeatEvent) return KeyEventResult.handled; + + shouldSave = false; + final pressed = event is KeyDownEvent; + final logicalKey = event.logicalKey; + + final modifierSetter = modifierSetters[logicalKey]; + if (modifierSetter != null) { + setState(() => modifierSetter.call(pressed)); + } else { + setState(() => key = pressed ? logicalKey : null); + } + + if (key != null && [alt, control, meta].any((pressed) => pressed)) { + shouldSave = true; + focusNode.unfocus(); + } + + return KeyEventResult.handled; + } + + void handleFocusChange(bool focused) { + setState(() { + hasFocus = focused; + if (focused) { + set(null); + shouldSave = true; + } else if (shouldSave) { + shouldSave = false; + final trigger = key; + final activator = trigger != null + ? SingleActivator( + trigger, + alt: alt, + control: control, + meta: meta, + shift: shift, + includeRepeats: false, + ) + : null; + widget.onSave?.call(activator); + } else { + set(widget.value); + } + }); + } + + @override + Widget build(BuildContext context) { + final keyLabel = key?.keyLabel ?? '...'; + final modifiers = [ + if (control) 'Ctrl', + if (alt) mpPlatform.altKey, + if (shift) 'Shift', + if (meta) mpPlatform.metaKey, + ].join('+'); + final keyCombination = modifiers.isNotEmpty + ? '$modifiers+$keyLabel' + : hasFocus + ? 'Input...' + : ''; + + return Focus( + focusNode: focusNode, + onFocusChange: handleFocusChange, + onKeyEvent: handleKeyEvent, + child: TapRegion( + onTapInside: (_) => focusNode.requestFocus(), + onTapOutside: (_) => focusNode.unfocus(), + child: Container( + width: 260, + height: 42, + alignment: Alignment.center, + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: hasFocus ? Colors.black : Colors.grey), + ), + child: Text(keyCombination), + ), + ), + ); + } +} diff --git a/src/client/gui/lib/settings/settings.dart b/src/client/gui/lib/settings/settings.dart index a55c8cbbf7..565bd1569c 100644 --- a/src/client/gui/lib/settings/settings.dart +++ b/src/client/gui/lib/settings/settings.dart @@ -11,16 +11,19 @@ class SettingsScreen extends StatelessWidget { @override Widget build(BuildContext context) { - const settings = Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - GeneralSettings(), - Divider(height: 60), - UsageSettings(), - Divider(height: 60), - VirtualizationSettings(), - ], + const settings = Padding( + padding: EdgeInsets.only(right: 15), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GeneralSettings(), + Divider(height: 60), + UsageSettings(), + Divider(height: 60), + VirtualizationSettings(), + ], + ), ); return const Scaffold( diff --git a/src/client/gui/lib/settings/usage_settings.dart b/src/client/gui/lib/settings/usage_settings.dart index aefcebc35e..d13f32425a 100644 --- a/src/client/gui/lib/settings/usage_settings.dart +++ b/src/client/gui/lib/settings/usage_settings.dart @@ -2,13 +2,14 @@ import 'dart:async'; import 'package:basics/basics.dart'; import 'package:flutter/material.dart' hide Switch; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fpdart/fpdart.dart' hide State; -// import 'package:hotkey_manager/hotkey_manager.dart'; - +import '../notifications/notifications_provider.dart'; import '../providers.dart'; import '../switch.dart'; +import 'hotkey.dart'; final primaryNameProvider = clientSettingProvider(primaryNameKey); final passphraseProvider = daemonSettingProvider(passphraseKey); @@ -26,6 +27,7 @@ class UsageSettings extends ConsumerWidget { final privilegedMounts = ref.watch(privilegedMountsProvider.select((value) { return value.valueOrNull?.toBoolOption.toNullable() ?? false; })); + final hotkey = ref.watch(hotkeyProvider); return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( @@ -40,10 +42,18 @@ class UsageSettings extends ConsumerWidget { }, ), const SizedBox(height: 20), + HotkeyField( + value: hotkey, + onSave: (newHotkey) => ref.read(hotkeyProvider.notifier).set(newHotkey), + ), + const SizedBox(height: 20), PassphraseField( hasPassphrase: hasPassphrase, onSave: (value) { - ref.read(passphraseProvider.notifier).set(value); + ref + .read(passphraseProvider.notifier) + .set(value) + .onError(ref.notifyError((e) => 'Failed to set passphrase: $e')); }, ), const SizedBox(height: 20), @@ -53,44 +63,18 @@ class UsageSettings extends ConsumerWidget { trailingSwitch: true, size: 30, onChanged: (value) { - ref.read(privilegedMountsProvider.notifier).set(value.toString()); + ref + .read(privilegedMountsProvider.notifier) + .set(value.toString()) + .onError( + ref.notifyError((e) => 'Failed to set privileged mounts: $e'), + ); }, ), ]); } } -// class HotkeyField extends StatefulWidget { -// final HotKey? initialHotkey; -// final ValueChanged onSave; -// -// const HotkeyField({ -// super.key, -// required this.initialHotkey, -// required this.onSave, -// }); -// -// @override -// State createState() => _HotkeyFieldState(); -// } - -// class _HotkeyFieldState extends State { -// late var hotkey = widget.initialHotkey; -// var changed = false; -// -// @override -// Widget build(BuildContext context) { -// return SettingField( -// icon: 'assets/primary_instance.svg', -// label: 'Primary instance name', -// onSave: () => widget.onSave(hotkey), -// onDiscard: () => hotkey = widget.initialHotkey, -// changed: changed, -// child: GestureDetector(), -// ); -// } -// } - class PrimaryNameField extends StatefulWidget { final String value; final ValueChanged onSave; @@ -107,6 +91,7 @@ class PrimaryNameField extends StatefulWidget { class _PrimaryNameFieldState extends State { final controller = TextEditingController(); + final formKey = GlobalKey>(); var changed = false; @override @@ -134,10 +119,90 @@ class _PrimaryNameFieldState extends State { Widget build(BuildContext context) { return SettingField( label: 'Primary instance name', - onSave: () => widget.onSave(controller.text), - onDiscard: () => controller.text = widget.value, + onSave: () { + if (formKey.currentState!.validate()) widget.onSave(controller.text); + }, + onDiscard: () { + controller.text = widget.value; + formKey.currentState!.validate(); + }, + changed: changed, + child: TextFormField( + key: formKey, + controller: controller, + validator: (value) { + value ??= ''; + if (value.isEmpty) return null; + if (RegExp(r'^[^A-Za-z]').hasMatch(value)) { + return 'Name must start with a letter'; + } + if (value.length < 2) return 'Name must be at least 2 characters'; + if (value.endsWith('-')) return 'Name must end in digit or letter'; + return null; + }, + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp('[-A-Za-z0-9]')) + ], + ), + ); + } +} + +class HotkeyField extends StatefulWidget { + final SingleActivator? value; + final ValueChanged onSave; + + const HotkeyField({ + super.key, + required this.value, + required this.onSave, + }); + + @override + State createState() => _HotkeyFieldState(); +} + +class _HotkeyFieldState extends State { + var changed = false; + late SingleActivator? value = widget.value; + final recorderState = GlobalKey(); + + static (bool?, bool?, bool?, bool?, LogicalKeyboardKey?) components( + SingleActivator? value, + ) { + return ( + value?.alt, + value?.control, + value?.meta, + value?.shift, + value?.trigger, + ); + } + + @override + void didUpdateWidget(HotkeyField oldWidget) { + super.didUpdateWidget(oldWidget); + changed = components(value) != components(widget.value); + } + + @override + Widget build(BuildContext context) { + return SettingField( + label: 'Primary instance hotkey', + onSave: () => widget.onSave(value), + onDiscard: () => setState(() { + recorderState.currentState?.set(widget.value); + changed = false; + }), changed: changed, - child: TextField(controller: controller), + child: HotkeyRecorder( + key: recorderState, + value: value, + onSave: (newHotkey) => setState(() { + value = newHotkey; + changed = components(value) != components(widget.value); + }), + ), ); } } @@ -188,7 +253,10 @@ class _PassphraseFieldState extends State { Widget build(BuildContext context) { return SettingField( label: 'Authentication passphrase', - onSave: () => widget.onSave(controller.text), + onSave: () { + widget.onSave(controller.text); + controller.clear(); + }, onDiscard: () => controller.text = '', changed: changed, child: TextField( @@ -218,7 +286,7 @@ class SettingField extends StatelessWidget { @override Widget build(BuildContext context) { - return Row(children: [ + return Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(child: Text(label, style: const TextStyle(fontSize: 16))), const SizedBox(width: 12), if (changed) ...[ diff --git a/src/client/gui/lib/settings/virtualization_settings.dart b/src/client/gui/lib/settings/virtualization_settings.dart index 41dd24610b..dd242c2e8d 100644 --- a/src/client/gui/lib/settings/virtualization_settings.dart +++ b/src/client/gui/lib/settings/virtualization_settings.dart @@ -1,9 +1,9 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../dropdown.dart'; +import '../notifications/notifications_provider.dart'; +import '../platform/platform.dart'; import '../providers.dart'; final driverProvider = daemonSettingProvider(driverKey); @@ -28,36 +28,27 @@ class VirtualizationSettings extends ConsumerWidget { label: 'Driver', width: 260, value: driver, - items: drivers, + items: mpPlatform.drivers, onChanged: (value) { if (value == driver) return; - ref.read(driverProvider.notifier).set(value!); + ref + .read(driverProvider.notifier) + .set(value!) + .onError(ref.notifyError((e) => 'Failed to set driver: $e')); }, ), const SizedBox(height: 20), if (networks.isNotEmpty) Dropdown( - label: 'Virtual interface', + label: 'Bridged network', width: 260, value: networks.contains(bridgedNetwork) ? bridgedNetwork : null, items: Map.fromIterable(networks), onChanged: (value) { - ref.read(bridgedNetworkProvider.notifier).set(value!); + ref.read(bridgedNetworkProvider.notifier).set(value!).onError( + ref.notifyError((e) => 'Failed to set bridged network: $e')); }, ), ]); } } - -final drivers = () { - if (Platform.isLinux) { - return const {'qemu': 'Qemu', 'lxd': 'LXD', 'libvirt': 'Libvirt'}; - } - if (Platform.isMacOS) { - return const {'qemu': 'Qemu', 'virtualbox': 'VirtualBox'}; - } - if (Platform.isWindows) { - return const {'hyperv': 'Hyper-V', 'virtualbox': 'VirtualBox'}; - } - throw const OSError('Unsupported OS'); -}(); diff --git a/src/client/gui/lib/sidebar.dart b/src/client/gui/lib/sidebar.dart index 0202fca824..bf662759b4 100644 --- a/src/client/gui/lib/sidebar.dart +++ b/src/client/gui/lib/sidebar.dart @@ -13,17 +13,34 @@ import 'settings/settings.dart'; import 'vm_details/terminal.dart'; import 'vm_table/vm_table_screen.dart'; -final sidebarKeyProvider = StateProvider((ref) { - ref.listen(vmNamesProvider, (_, vmNames) { - final key = ref.controller.state; - if (key.startsWith('vm-') && !vmNames.contains(key.withoutPrefix('vm-'))) { - ref.invalidateSelf(); +extension on String { + String? get sidebarVmName => startsWith('vm-') ? withoutPrefix('vm-') : null; +} + +class SidebarKeyNotifier extends Notifier { + @override + String build() { + ref.listen(vmNamesProvider, (_, names) { + final vmName = state.sidebarVmName; + if (vmName != null && !names.contains(vmName)) ref.invalidateSelf(); + }); + + return CatalogueScreen.sidebarKey; + } + + void set(String key) { + if (key.sidebarVmName != null) { + ref.read(vmVisitedProvider(key).notifier).state = true; } - }); - ref.listenSelf((_, __) => FocusManager.instance.primaryFocus?.unfocus()); - return CatalogueScreen.sidebarKey; -}); + state = key; + } +} +final sidebarKeyProvider = NotifierProvider( + SidebarKeyNotifier.new, +); + +final vmVisitedProvider = StateProvider.family((_, __) => false); final sidebarExpandedProvider = StateProvider((_) => false); final sidebarPushContentProvider = StateProvider((_) => false); Timer? sidebarExpandTimer; @@ -39,6 +56,7 @@ class SideBar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final selectedSidebarKey = ref.watch(sidebarKeyProvider); + final sidebarKeyNotifier = sidebarKeyProvider.notifier; final vmNames = ref.watch(vmNamesProvider); final expanded = ref.watch(sidebarExpandedProvider); final pushContent = ref.watch(sidebarPushContentProvider); @@ -49,8 +67,9 @@ class SideBar extends ConsumerWidget { icon: SvgPicture.asset('assets/catalogue.svg'), selected: isSelected(CatalogueScreen.sidebarKey), label: 'Catalogue', - onPressed: () => ref.read(sidebarKeyProvider.notifier).state = - CatalogueScreen.sidebarKey, + onPressed: () { + ref.read(sidebarKeyNotifier).set(CatalogueScreen.sidebarKey); + }, ); final instances = SidebarEntry( @@ -59,24 +78,27 @@ class SideBar extends ConsumerWidget { !expanded && selectedSidebarKey.startsWith('vm-'), label: 'Instances', badge: vmNames.length.toString(), - onPressed: () => ref.read(sidebarKeyProvider.notifier).state = - VmTableScreen.sidebarKey, + onPressed: () { + ref.read(sidebarKeyProvider.notifier).set(VmTableScreen.sidebarKey); + }, ); final help = SidebarEntry( icon: SvgPicture.asset('assets/help.svg'), selected: isSelected(HelpScreen.sidebarKey), label: 'Help', - onPressed: () => - ref.read(sidebarKeyProvider.notifier).state = HelpScreen.sidebarKey, + onPressed: () { + ref.read(sidebarKeyNotifier).set(HelpScreen.sidebarKey); + }, ); final settings = SidebarEntry( icon: SvgPicture.asset('assets/settings.svg'), selected: isSelected(SettingsScreen.sidebarKey), label: 'Settings', - onPressed: () => ref.read(sidebarKeyProvider.notifier).state = - SettingsScreen.sidebarKey, + onPressed: () { + ref.read(sidebarKeyNotifier).set(SettingsScreen.sidebarKey); + }, ); final pinSidebarButton = Material( @@ -119,7 +141,8 @@ class SideBar extends ConsumerWidget { final vmEntries = vmNames.map((name) { final key = 'vm-$name'; - final hasShells = ref.watch(vmShellsProvider(name).select((n) => n > 0)); + final hasShells = + ref.watch(runningShellsProvider(name).select((n) => n > 0)); return SidebarEntry( key: ValueKey(key), icon: Opacity( @@ -132,7 +155,9 @@ class SideBar extends ConsumerWidget { ), selected: isSelected(key) && expanded, label: name, - onPressed: () => ref.read(sidebarKeyProvider.notifier).state = key, + onPressed: () { + ref.read(sidebarKeyNotifier).set(key); + }, ); }); diff --git a/src/client/gui/lib/tray_menu.dart b/src/client/gui/lib/tray_menu.dart index 4fe48f76b5..c676c018c2 100644 --- a/src/client/gui/lib/tray_menu.dart +++ b/src/client/gui/lib/tray_menu.dart @@ -5,19 +5,24 @@ import 'package:basics/basics.dart'; import 'package:collection/collection.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:multipass_gui/vm_details/terminal.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:synchronized/synchronized.dart'; import 'package:tray_menu/tray_menu.dart'; import 'package:window_manager/window_manager.dart'; import 'ffi.dart'; +import 'platform/platform.dart'; import 'providers.dart'; +import 'sidebar.dart'; import 'vm_action.dart'; +import 'vm_details/terminal_tabs.dart'; +import 'vm_details/vm_details.dart'; final trayMenuDataProvider = Provider.autoDispose((ref) { - return ( - ref.watch(clientSettingProvider(primaryNameKey)), - ref.watch(daemonAvailableProvider) ? ref.watch(vmStatusesProvider) : null, - ); + return ref.watch(daemonAvailableProvider) + ? ref.watch(vmStatusesProvider) + : null; }); final daemonVersionProvider = Provider((ref) { @@ -33,31 +38,28 @@ final daemonVersionProvider = Provider((ref) { Future _iconFilePath() async { final dataDir = await getApplicationSupportDirectory(); - final iconName = Platform.isMacOS - ? 'icon_template.png' - : Platform.isWindows - ? 'icon.ico' - : 'icon.png'; + final iconName = mpPlatform.trayIconFile; final iconFile = File('${dataDir.path}/$iconName'); final data = await rootBundle.load('assets/$iconName'); await iconFile.writeAsBytes(data.buffer.asUint8List()); return iconFile.path; } -const _separatorInstancesKey = 'separator-instances'; -const _separatorPrimaryKey = 'separator-primary'; +const _separatorVmsKey = 'separator-vms'; const _errorKey = 'error'; const _separatorErrorKey = 'separator-error'; const _separatorAboutKey = 'separator-about'; Future setupTrayMenu(ProviderContainer providerContainer) async { - await TrayMenu.instance.addLabel( - 'toggle-window', - label: 'Toggle window', - callback: (_, __) async => await windowManager.isVisible() - ? windowManager.hide() - : windowManager.show(), - ); + if (mpPlatform.showToggleWindow) { + await TrayMenu.instance.addLabel( + 'toggle-window', + label: 'Toggle window', + callback: (_, __) async => await windowManager.isVisible() + ? windowManager.hide() + : windowManager.show(), + ); + } await TrayMenu.instance.addSeparator(_separatorAboutKey); final aboutSubmenu = await TrayMenu.instance.addSubmenu( @@ -91,35 +93,32 @@ Future setupTrayMenu(ProviderContainer providerContainer) async { await TrayMenu.instance.show(await _iconFilePath()); - var updating = Completer(); - updating.complete(); - providerContainer.listen(trayMenuDataProvider, (previous, next) async { - final (previousPrimary, previousVmData) = previous!; - final (nextPrimary, nextVmData) = next; - if (!updating.isCompleted) await updating.future; - updating = Completer(); - nextVmData == null - ? await _setTrayMenuError() - : await _updateTrayMenu( - providerContainer.read(grpcClientProvider), - previousPrimary, + final lock = Lock(); + providerContainer.listen( + trayMenuDataProvider, + (previousVmData, nextVmData) async { + lock.synchronized(() async { + if (nextVmData == null) { + await _setTrayMenuError(); + } else { + await _updateTrayMenu( + providerContainer, previousVmData?.toMap() ?? {}, - nextPrimary, nextVmData.toMap(), ); - updating.complete(); - }); + } + }); + }, + ); } Future _setTrayMenuError() async { - final keys = TrayMenu.instance.keys - .where((key) => key.startsWith('instance-') || key.startsWith('primary-')) - .toList(); + final keys = + TrayMenu.instance.keys.where((key) => key.startsWith('vm-')).toList(); for (final key in keys) { await TrayMenu.instance.remove(key); } - await TrayMenu.instance.remove(_separatorPrimaryKey); - await TrayMenu.instance.remove(_separatorInstancesKey); + await TrayMenu.instance.remove(_separatorVmsKey); const errorMessage = 'Failed retrieving instance data'; final errorLabel = TrayMenu.instance.get(_errorKey); @@ -143,83 +142,35 @@ extension on InstanceStatus_Status { } Future _updateTrayMenu( - final GrpcClient grpcClient, - final String previousPrimary, + final ProviderContainer providerContainer, final Map previousVms, - final String nextPrimary, final Map nextVms, ) async { - startCallback(String name) => (_, __) => grpcClient.start([name]); - stopCallback(String name) => (_, __) => grpcClient.stop([name]); - const primaryStartKey = 'primary-start'; - const primaryStopKey = 'primary-stop'; + final grpcClient = providerContainer.read(grpcClientProvider); await TrayMenu.instance.remove(_errorKey); await TrayMenu.instance.remove(_separatorErrorKey); - if (nextPrimary.isEmpty) { - await TrayMenu.instance.remove(primaryStartKey); - await TrayMenu.instance.remove(primaryStopKey); - await TrayMenu.instance.remove(_separatorPrimaryKey); - } else { - final status = nextVms.remove(nextPrimary); - final startLabel = - status == null ? 'Start' : 'Start "$nextPrimary" (${status.label})'; - final startEnabled = VmAction.start.allowedStatuses.contains(status); - final stopEnabled = VmAction.stop.allowedStatuses.contains(status); - final primaryStart = TrayMenu.instance.get(primaryStartKey); - final primaryStop = TrayMenu.instance.get(primaryStopKey); - if (primaryStart == null || primaryStop == null) { - final beforeKey = TrayMenu.instance.keys.contains(_separatorInstancesKey) - ? _separatorInstancesKey - : _separatorAboutKey; - await TrayMenu.instance.addSeparator( - _separatorPrimaryKey, - before: beforeKey, - ); - await TrayMenu.instance.addLabel( - primaryStartKey, - label: startLabel, - enabled: startEnabled, - before: beforeKey, - callback: startCallback(nextPrimary), - ); - await TrayMenu.instance.addLabel( - primaryStopKey, - label: 'Stop', - enabled: stopEnabled, - before: beforeKey, - callback: stopCallback(nextPrimary), - ); - } else { - await primaryStart.setLabel(startLabel); - await primaryStart.setEnabled(startEnabled); - primaryStart.callback = startCallback(nextPrimary); - await primaryStop.setEnabled(stopEnabled); - primaryStop.callback = stopCallback(nextPrimary); - } - } - for (final name in previousVms.keys.whereNot(nextVms.containsKey)) { - await TrayMenu.instance.remove('instance-$name'); + await TrayMenu.instance.remove('vm-$name'); } if (nextVms.isEmpty) { - await TrayMenu.instance.remove(_separatorInstancesKey); - } else if (!TrayMenu.instance.keys.contains(_separatorInstancesKey)) { + await TrayMenu.instance.remove(_separatorVmsKey); + } else if (!TrayMenu.instance.keys.contains(_separatorVmsKey)) { await TrayMenu.instance.addSeparator( - _separatorInstancesKey, + _separatorVmsKey, before: _separatorAboutKey, ); } for (final MapEntry(key: name, value: status) in nextVms.entries) { - final key = 'instance-$name'; + final key = 'vm-$name'; final previousStatus = previousVms[name]; final label = '$name (${status.label})'; final startEnabled = VmAction.start.allowedStatuses.contains(status); final stopEnabled = VmAction.stop.allowedStatuses.contains(status); - if (previousStatus == null || name == previousPrimary) { + if (previousStatus == null) { final submenu = await TrayMenu.instance.addSubmenu( key, label: label, @@ -229,13 +180,35 @@ Future _updateTrayMenu( 'start', label: 'Start', enabled: startEnabled, - callback: startCallback(name), + callback: (_, __) => grpcClient.start([name]), ); await submenu.addLabel( 'stop', label: 'Stop', enabled: stopEnabled, - callback: stopCallback(name), + callback: (_, __) => grpcClient.stop([name]), + ); + await submenu.addSeparator('separator'); + await submenu.addLabel( + 'open', + label: 'Open in Multipass', + callback: (_, __) { + providerContainer + .read(vmScreenLocationProvider(name).notifier) + .state = VmDetailsLocation.shells; + providerContainer.read(sidebarKeyProvider.notifier).set(key); + final (:ids, :currentIndex) = + providerContainer.read(shellIdsProvider(name)); + final terminalIdentifier = ( + vmName: name, + shellId: ids[currentIndex], + ); + final provider = terminalProvider(terminalIdentifier); + if (providerContainer.exists(provider)) { + providerContainer.read(provider.notifier).start(); + } + windowManager.show(); + }, ); } else { final submenu = TrayMenu.instance.get(key); diff --git a/src/client/gui/lib/vm_details/mount_points.dart b/src/client/gui/lib/vm_details/mount_points.dart index dcbd38cc61..28b34e5d56 100644 --- a/src/client/gui/lib/vm_details/mount_points.dart +++ b/src/client/gui/lib/vm_details/mount_points.dart @@ -2,14 +2,20 @@ import 'package:basics/basics.dart'; import 'package:built_collection/built_collection.dart'; import 'package:flutter/material.dart'; +import '../extensions.dart'; import '../ffi.dart'; import '../providers.dart'; class MountPoint extends StatefulWidget { final VoidCallback onDelete; - final Function(String source, String target) onSaved; + final Function(MountRequest mountRequest) onSaved; final String initialSource; final String initialTarget; + final Widget Function( + Widget sourceField, + Widget targetField, + Widget deleteButton, + ) builder; const MountPoint({ super.key, @@ -17,6 +23,7 @@ class MountPoint extends StatefulWidget { required this.onSaved, required this.initialSource, required this.initialTarget, + required this.builder, }); @override @@ -24,99 +31,102 @@ class MountPoint extends StatefulWidget { } class _MountPointState extends State { - final width = 265.0; - String? savedSource; - String? savedTarget; + String? source; + String? target; + String? targetHint; void save() { - if (savedSource != null && savedTarget != null) { - widget.onSaved(savedSource!, savedTarget!); - savedSource = null; - savedTarget = null; - } + final (savedSource, savedTarget) = (source, target); + if (savedSource.isNullOrBlank || savedTarget == null) return; + + final targetPath = TargetPathInfo( + targetPath: savedTarget.isBlank ? savedSource : savedTarget, + ); + + final request = MountRequest( + sourcePath: savedSource, + targetPaths: [targetPath], + mountMaps: MountMaps( + uidMappings: [IdMap(hostId: uid(), instanceId: defaultId())], + gidMappings: [IdMap(hostId: gid(), instanceId: defaultId())], + ), + ); + + widget.onSaved(request); + source = null; + target = null; } @override Widget build(BuildContext context) { - return Row(children: [ - SizedBox( - width: width, - child: TextFormField( - initialValue: widget.initialSource, - onSaved: (value) { - savedSource = value; - save(); - }, - ), - ), - const SizedBox(width: 24), - SizedBox( - width: width, - child: TextFormField( - initialValue: widget.initialTarget, - onSaved: (value) { - savedTarget = value; - save(); - }, - ), - ), - const SizedBox(width: 24), - SizedBox( - height: 42, - child: OutlinedButton( - onPressed: widget.onDelete, - child: const Icon(Icons.delete_outline, color: Colors.grey), - ), + final sourceField = TextFormField( + initialValue: widget.initialSource, + onChanged: (value) => setState(() => targetHint = value), + onSaved: (value) => source = value ?? '', + validator: (value) { + return value.isNullOrBlank ? 'Source cannot be empty' : null; + }, + ); + + final targetField = TextFormField( + initialValue: widget.initialTarget, + decoration: InputDecoration(hintText: targetHint), + onSaved: (value) { + target = value ?? ''; + save(); + }, + ); + + final deleteButton = SizedBox( + height: 42, + child: OutlinedButton( + onPressed: widget.onDelete, + child: const Icon(Icons.delete_outline, color: Colors.grey), ), - ]); + ); + + return widget.builder(sourceField, targetField, deleteButton); } } class MountPointList extends StatefulWidget { - final BuiltList initialMountRequests; - final Function(BuiltList) onSaved; + final double? width; + final ValueChanged> onSaved; + final bool showLabels; - MountPointList({ + const MountPointList({ super.key, + this.width, required this.onSaved, - BuiltList? initialMountRequests, - }) : initialMountRequests = initialMountRequests ?? BuiltList(); + this.showLabels = true, + }); @override State createState() => _MountPointListState(); } class _MountPointListState extends State { + static const gap = 24.0; final mounts = {}; - final mountRequests = []; + final savedMountRequests = []; - void save(MountRequest request) { - mountRequests.add(request); - if (mounts.length == mountRequests.length) { - widget.onSaved(mountRequests.build()); - mountRequests.clear(); - } - } + void addEntry() => setState(() => mounts[UniqueKey()] = ('', '')); - void setExistingMounts() { - for (final mount in widget.initialMountRequests) { - mounts[UniqueKey()] = ( - mount.sourcePath, - mount.targetPaths.first.targetPath, - ); + void onEntryDeleted(Key key) => setState(() => mounts.remove(key)); + + void onEntrySaved(MountRequest request) { + savedMountRequests.add(request); + if (mounts.length == savedMountRequests.length) { + widget.onSaved(savedMountRequests.build()); + savedMountRequests.clear(); } } - @override - void initState() { - super.initState(); - setExistingMounts(); - } - - @override - void didUpdateWidget(MountPointList oldWidget) { - super.didUpdateWidget(oldWidget); - setExistingMounts(); + Widget widthWrapper(Widget child) { + final width = widget.width; + return width == null + ? Expanded(child: child) + : SizedBox(width: width, child: child); } @override @@ -125,46 +135,134 @@ class _MountPointListState extends State { final MapEntry(:key, value: (initialSource, initialTarget)) = entry; return Container( key: key, - margin: const EdgeInsets.symmetric(vertical: 12), + margin: const EdgeInsets.symmetric(vertical: gap / 2), child: MountPoint( initialSource: initialSource, initialTarget: initialTarget, - onDelete: () => setState(() => mounts.remove(key)), - onSaved: (source, target) { - final request = MountRequest( - sourcePath: source, - targetPaths: [ - TargetPathInfo(targetPath: target.isBlank ? source : target) - ], - mountMaps: MountMaps( - uidMappings: [IdMap(hostId: uid(), instanceId: default_id())], - gidMappings: [IdMap(hostId: gid(), instanceId: default_id())], - ), - ); - save(request); - }, + onSaved: onEntrySaved, + onDelete: () => onEntryDeleted(key), + builder: (sourceField, targetField, deleteButton) => Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + widthWrapper(sourceField), + widthWrapper(targetField), + deleteButton, + ].gap(width: gap).toList(), + ), ), ); }).toList(); final addMountPoint = OutlinedButton.icon( - onPressed: () => setState(() => mounts[UniqueKey()] = ('', '')), + onPressed: addEntry, label: const Text('Add mount point'), icon: const Icon(Icons.add), ); - const labels = Row(children: [ - SizedBox( - width: 265 + 24, - child: Text('Source path', style: TextStyle(fontSize: 16)), + final labels = DefaultTextStyle.merge( + style: const TextStyle(fontSize: 16), + child: Row( + children: [ + widthWrapper(const Text('Source path')), + widthWrapper(const Text('Target path')), + const SizedBox(width: 55), + ].gap(width: gap).toList(), ), - Text('Target path', style: TextStyle(fontSize: 16)), - ]); + ); return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (mountPoints.isNotEmpty) labels, + if (widget.showLabels && mountPoints.isNotEmpty) labels, ...mountPoints, addMountPoint, ]); } } + +class MountPointsView extends StatefulWidget { + final Iterable existingMounts; + final bool editing; + final ValueChanged> onSaved; + + const MountPointsView({ + super.key, + required this.existingMounts, + required this.editing, + required this.onSaved, + }); + + @override + State createState() => _MountPointsViewState(); +} + +class _MountPointsViewState extends State { + static const deleteButtonSize = 25.0; + + final toUnmount = {}; + + @override + void didUpdateWidget(MountPointsView oldWidget) { + super.didUpdateWidget(oldWidget); + toUnmount.clear(); + } + + Widget buildEntry(MountPaths mount) { + final contains = toUnmount.contains(mount.targetPath); + + final button = Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: IconButton( + constraints: const BoxConstraints(), + icon: Icon(contains ? Icons.undo : Icons.delete), + iconSize: deleteButtonSize, + padding: EdgeInsets.zero, + splashRadius: 20, + onPressed: () => setState(() { + if (!toUnmount.remove(mount.targetPath)) { + toUnmount.add(mount.targetPath); + } + }), + ), + ); + + return DefaultTextStyle.merge( + style: TextStyle( + decoration: contains ? TextDecoration.lineThrough : null, + fontSize: 16, + ), + child: SizedBox( + height: 30, + child: Row(children: [ + Expanded(child: Text(mount.sourcePath)), + Expanded(child: Text(mount.targetPath)), + widget.editing + ? button + : const SizedBox(width: deleteButtonSize + 32), + ]), + ), + ); + } + + @override + Widget build(BuildContext context) { + return FormField>( + onSaved: (_) => widget.onSaved(toUnmount.toBuiltList()), + builder: (_) { + return Column(children: [ + if (widget.existingMounts.isNotEmpty) + DefaultTextStyle.merge( + style: const TextStyle(fontWeight: FontWeight.bold), + child: const Row(children: [ + Expanded(child: Text('SOURCE PATH')), + Expanded(child: Text('TARGET PATH')), + SizedBox(width: deleteButtonSize + 32), + ]), + ), + for (final mount in widget.existingMounts) ...[ + const Divider(height: 10), + buildEntry(mount), + ], + ]); + }, + ); + } +} diff --git a/src/client/gui/lib/vm_details/terminal.dart b/src/client/gui/lib/vm_details/terminal.dart index a43e8c161a..ca2b98e99b 100644 --- a/src/client/gui/lib/vm_details/terminal.dart +++ b/src/client/gui/lib/vm_details/terminal.dart @@ -1,102 +1,192 @@ import 'dart:convert'; import 'dart:isolate'; -import 'dart:math'; import 'package:async/async.dart'; import 'package:dartssh2/dartssh2.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:synchronized/synchronized.dart'; import 'package:xterm/xterm.dart'; import '../logger.dart'; +import '../notifications.dart'; +import '../platform/platform.dart'; import '../providers.dart'; -final vmShellsProvider = StateProvider.autoDispose.family((_, __) { +final runningShellsProvider = + StateProvider.autoDispose.family((_, __) { return 0; }); -class VmTerminal extends ConsumerStatefulWidget { - final String name; +class ShellId { + final int id; + final bool autostart; - const VmTerminal(this.name, {super.key}); + ShellId(this.id, {this.autostart = true}); @override - ConsumerState createState() => VmTerminalState(); + String toString() => 'ShellId{$id}'; } -class VmTerminalState extends ConsumerState { - Terminal? terminal; +typedef TerminalIdentifier = ({String vmName, ShellId shellId}); + +class TerminalNotifier + extends AutoDisposeFamilyNotifier { + final lock = Lock(); Isolate? isolate; - final scrollController = ScrollController(); - final focusNode = FocusNode(); + late final vmStatusProvider = vmInfoProvider(arg.vmName).select((info) { + return info.instanceStatus.status == Status.RUNNING; + }); @override - void dispose() { - isolate?.kill(priority: Isolate.immediate); - scrollController.dispose(); - focusNode.dispose(); - super.dispose(); + Terminal? build(TerminalIdentifier arg) { + ref.onDispose(_dispose); + if (arg.shellId.autostart) { + lock.synchronized(_initShell).then((value) => state = value); + } + ref.listen(vmStatusProvider, (previous, next) { + if ((previous ?? false) && !next) stop(); + }); + return null; } - Future initTerminal() async { - final vmShellsNotifier = ref.read(vmShellsProvider(widget.name).notifier); - final thisTerminal = terminal; - if (thisTerminal == null) return; + Future _initShell() async { + final currentState = stateOrNull; + if (currentState != null) return currentState; - final sshInfo = await ref.read(grpcClientProvider).sshInfo(widget.name); - if (sshInfo == null) return; + final running = ref.read(vmStatusProvider); + if (!running) return null; + + final grpcClient = ref.read(grpcClientProvider); + final sshInfo = await grpcClient.sshInfo(arg.vmName).onError((err, stack) { + ref + .notifyError((error) => 'Failed to get SSH information: $err') + .call(err, stack); + return null; + }); + if (sshInfo == null) return null; + final terminal = Terminal(maxLines: 10000); final receiver = ReceivePort(); final errorReceiver = ReceivePort(); final exitReceiver = ReceivePort(); - isolate = await Isolate.spawn( - sshIsolate, - SshShellInfo( - sender: receiver.sendPort, - width: thisTerminal.viewWidth, - height: thisTerminal.viewHeight, - sshInfo: sshInfo, - ), - onError: errorReceiver.sendPort, - onExit: exitReceiver.sendPort, - errorsAreFatal: true, - ); - - vmShellsNotifier.update((state) => state + 1); errorReceiver.listen((es) { + final (Object? error, String? stack) = (es[0], es[1]); logger.e( - 'Error from ${widget.name} ssh isolate', - error: es[0], - stackTrace: es[1] != null ? StackTrace.fromString(es[1]) : null, + 'Error from $arg ssh isolate', + error: error, + stackTrace: stack != null ? StackTrace.fromString(stack) : null, ); }); + exitReceiver.listen((_) { - logger.d('Exited ${widget.name} ssh isolate'); + logger.d('Exited $arg ssh isolate'); receiver.close(); errorReceiver.close(); exitReceiver.close(); - vmShellsNotifier.update((state) => max(0, state - 1)); - if (mounted) setState(() => terminal = null); + stop(); }); + receiver.listen((event) { switch (event) { case final SendPort sender: - thisTerminal.onOutput = sender.send; - thisTerminal.onResize = (w, h, pw, ph) => sender.send([w, h, pw, ph]); + terminal.onOutput = sender.send; + terminal.onResize = (w, h, pw, ph) => sender.send([w, h, pw, ph]); case final String data: - thisTerminal.write(data); + terminal.write(data); case null: - logger.i('Ssh session for ${widget.name} has exited'); - isolate?.kill(priority: Isolate.immediate); + stop(); } }); + + isolate = await Isolate.spawn( + sshIsolate, + SshShellInfo( + sender: receiver.sendPort, + width: terminal.viewWidth, + height: terminal.viewHeight, + sshInfo: sshInfo, + ), + onError: errorReceiver.sendPort, + onExit: exitReceiver.sendPort, + errorsAreFatal: true, + ); + + ref.read(runningShellsProvider(arg.vmName).notifier).update((state) { + return state + 1; + }); + return terminal; + } + + Future start() async { + state = await lock.synchronized(_initShell); + } + + void stop() { + _dispose(); + state = null; + } + + void _dispose() { + isolate?.kill(priority: Isolate.immediate); + if (isolate != null) { + ref + .read(runningShellsProvider(arg.vmName).notifier) + .update((state) => state - 1); + } + isolate = null; + } +} + +final terminalProvider = NotifierProvider.autoDispose + .family( + TerminalNotifier.new); + +class VmTerminal extends ConsumerStatefulWidget { + final String name; + final ShellId id; + final bool isCurrent; + + const VmTerminal( + this.name, + this.id, { + super.key, + this.isCurrent = false, + }); + + @override + ConsumerState createState() => _VmTerminalState(); +} + +class _VmTerminalState extends ConsumerState { + final scrollController = ScrollController(); + final focusNode = FocusNode(); + + @override + void initState() { + super.initState(); focusNode.requestFocus(); } + @override + void dispose() { + scrollController.dispose(); + focusNode.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(VmTerminal oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isCurrent) focusNode.requestFocus(); + } + @override Widget build(BuildContext context) { + final terminalIdentifier = (vmName: widget.name, shellId: widget.id); + final terminal = ref.watch(terminalProvider(terminalIdentifier)); final vmRunning = ref.watch(vmInfoProvider(widget.name).select((info) { return info.instanceStatus.status == Status.RUNNING; })); @@ -113,8 +203,7 @@ class VmTerminalState extends ConsumerState { }), ); - final thisTerminal = terminal; - if (thisTerminal == null) { + if (terminal == null) { return Container( color: const Color(0xff380c2a), alignment: Alignment.center, @@ -126,12 +215,11 @@ class VmTerminalState extends ConsumerState { const SizedBox(height: 12), OutlinedButton( style: buttonStyle, - onPressed: !vmRunning - ? null - : () => setState(() { - terminal = Terminal(maxLines: 10000); - initTerminal(); - }), + onPressed: vmRunning + ? () => ref + .read(terminalProvider(terminalIdentifier).notifier) + .start() + : null, child: const Text('Open shell'), ), const SizedBox(height: 32), @@ -145,11 +233,10 @@ class VmTerminalState extends ConsumerState { thickness: 9, child: ClipRect( child: TerminalView( - thisTerminal, + terminal, scrollController: scrollController, - autofocus: true, focusNode: focusNode, - shortcuts: const {}, + shortcuts: mpPlatform.terminalShortcuts, hardwareKeyboardOnly: true, padding: const EdgeInsets.all(4), theme: const TerminalTheme( diff --git a/src/client/gui/lib/vm_details/terminal_tabs.dart b/src/client/gui/lib/vm_details/terminal_tabs.dart index 44898d6a5c..881987136d 100644 --- a/src/client/gui/lib/vm_details/terminal_tabs.dart +++ b/src/client/gui/lib/vm_details/terminal_tabs.dart @@ -1,10 +1,60 @@ import 'package:basics/basics.dart'; +import 'package:built_collection/built_collection.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'terminal.dart'; +typedef ShellIds = ({ + BuiltList ids, +// this is the index of the currently selected shell id + int currentIndex, +}); + +class ShellIdsNotifier extends AutoDisposeFamilyNotifier { + @override + ShellIds build(String arg) => (ids: [ShellId(1)].build(), currentIndex: 0); + + void add() { + final ids = state.ids; + final existingIds = state.ids.map((shellId) => shellId.id).toBuiltSet(); + final newShellId = 1.to(1000).whereNot(existingIds.contains).first; + state = ( + ids: ids.rebuild((ids) => ids.add(ShellId(newShellId))), + currentIndex: ids.length, + ); + } + + void remove(int index) { + var (:ids, :currentIndex) = state; + final idsBuilder = ids.toBuilder(); + idsBuilder.removeAt(index); + if (index < currentIndex) currentIndex -= 1; + if (idsBuilder.isEmpty) idsBuilder.add(ShellId(1, autostart: false)); + currentIndex = currentIndex.clamp(0, idsBuilder.length - 1); + state = (ids: idsBuilder.build(), currentIndex: currentIndex); + } + + void reorder(int oldIndex, int newIndex) { + if (oldIndex < newIndex) newIndex -= 1; + final reorderedIds = state.ids.rebuild((ids) { + final id = ids.removeAt(oldIndex); + ids.insert(newIndex, id); + }); + + state = (ids: reorderedIds, currentIndex: newIndex); + } + + void setCurrent(int index) { + state = (ids: state.ids, currentIndex: index); + } +} + +final shellIdsProvider = NotifierProvider.autoDispose + .family(ShellIdsNotifier.new); + class Tab extends StatelessWidget { final String title; final bool selected; @@ -28,10 +78,7 @@ class Tab extends StatelessWidget { child: SvgPicture.asset( 'assets/ubuntu.svg', width: 12, - colorFilter: const ColorFilter.mode( - Colors.white, - BlendMode.srcIn, - ), + colorFilter: const ColorFilter.mode(Colors.white, BlendMode.srcIn), ), ); @@ -41,10 +88,7 @@ class Tab extends StatelessWidget { title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w300, - ), + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w300), ); final closeButton = Material( @@ -81,56 +125,37 @@ class Tab extends StatelessWidget { } } -class TerminalTabs extends StatefulWidget { +class TerminalTabs extends ConsumerWidget { final String name; const TerminalTabs(this.name, {super.key}); @override - State createState() => _TerminalTabsState(); -} - -class _TerminalTabsState extends State { - var _currentIndex = 0; - - get currentIndex => _currentIndex; - - set currentIndex(value) { - final (_, key) = shells[value]; - FocusManager.instance.primaryFocus?.unfocus(); - key.currentState?.focusNode.requestFocus(); - _currentIndex = value; - } - - final shells = [(1, GlobalKey())]; - - @override - Widget build(BuildContext context) { - final tabs = shells.indexed.map((e) { - final (orderIndex, (shellId, _)) = e; - return ReorderableDragStartListener( - key: ValueKey(shellId), - index: orderIndex, + Widget build(BuildContext context, WidgetRef ref) { + final provider = shellIdsProvider(name); + final notifier = provider.notifier; + final (:ids, :currentIndex) = ref.watch(provider); + + final tabsAndShells = ids.mapIndexed((index, shellId) { + final tab = ReorderableDragStartListener( + key: ValueKey(shellId.id), + index: index, child: Tab( - title: 'Shell $shellId', - selected: orderIndex == currentIndex, - onTap: () => setState(() => currentIndex = orderIndex), - onClose: () { - if (shells.length == 1) return; - setState(() { - shells.removeAt(orderIndex); - if (orderIndex < currentIndex) currentIndex -= 1; - currentIndex = currentIndex.clamp(0, shells.length - 1); - }); - }, + title: 'Shell ${shellId.id}', + selected: index == currentIndex, + onTap: () => ref.read(notifier).setCurrent(index), + onClose: () => ref.read(notifier).remove(index), ), ); - }); - final terminals = shells.map((e) { - final (_, terminalKey) = e; - return VmTerminal(widget.name, key: terminalKey); - }); + final shell = VmTerminal( + name, + shellId, + isCurrent: index == currentIndex, + ); + + return (tab: tab, shell: shell); + }).toList(); final addShellButton = Material( color: Colors.transparent, @@ -138,12 +163,7 @@ class _TerminalTabsState extends State { hoverColor: Colors.white24, splashRadius: 10, icon: const Icon(Icons.add, color: Colors.white, size: 20), - onPressed: () => setState(() { - final shellIds = shells.map((e) => e.$1).toSet(); - final newShellId = 1.to(1000).whereNot(shellIds.contains).first; - shells.add((newShellId, GlobalKey())); - currentIndex = shells.length - 1; - }), + onPressed: () => ref.read(notifier).add(), ), ); @@ -151,20 +171,19 @@ class _TerminalTabsState extends State { buildDefaultDragHandles: false, footer: addShellButton, scrollDirection: Axis.horizontal, - onReorderStart: (index) => setState(() => currentIndex = index), - onReorder: (oldIndex, newIndex) => setState(() { - if (oldIndex < newIndex) newIndex -= 1; - final item = shells.removeAt(oldIndex); - shells.insert(newIndex, item); - currentIndex = newIndex; - }), - children: tabs.toList(), + onReorderStart: (index) => ref.read(notifier).setCurrent(index), + onReorder: (oldIndex, newIndex) { + ref.read(notifier).reorder(oldIndex, newIndex); + }, + children: tabsAndShells.map((e) => e.tab).toList(), ); - final shellStack = IndexedStack( - sizing: StackFit.expand, - index: currentIndex, - children: terminals.toList(), + final shellStack = FocusScope( + child: IndexedStack( + sizing: StackFit.expand, + index: currentIndex, + children: tabsAndShells.map((e) => e.shell).toList(), + ), ); return Column(children: [ diff --git a/src/client/gui/lib/vm_details/vm_action_buttons.dart b/src/client/gui/lib/vm_details/vm_action_buttons.dart index aaae58d6b9..ecc0a9b7f5 100644 --- a/src/client/gui/lib/vm_details/vm_action_buttons.dart +++ b/src/client/gui/lib/vm_details/vm_action_buttons.dart @@ -19,15 +19,15 @@ class VmActionButtons extends ConsumerWidget { Future Function(Iterable) function, ) { return (action) { - final notification = OperationNotification( - text: '${action.continuousTense} $name', - future: function([name]).then((_) { - return '${action.pastTense} $name'; - }).onError((_, __) { - throw 'Failed to ${action.name.toLowerCase()} $name'; - }), + final notificationsNotifier = ref.read(notificationsProvider.notifier); + notificationsNotifier.addOperation( + function([name]), + loading: '${action.continuousTense} $name', + onSuccess: (_) => '${action.pastTense} $name', + onError: (error) { + return 'Failed to ${action.name.toLowerCase()} $name: $error'; + }, ); - ref.read(notificationsProvider.notifier).add(notification); }; } diff --git a/src/client/gui/lib/vm_details/vm_details.dart b/src/client/gui/lib/vm_details/vm_details.dart index e9b8a2b9e5..20f65cd3b4 100644 --- a/src/client/gui/lib/vm_details/vm_details.dart +++ b/src/client/gui/lib/vm_details/vm_details.dart @@ -9,6 +9,7 @@ import 'package:intl/intl.dart'; import '../extensions.dart'; import '../ffi.dart'; +import '../notifications.dart'; import '../providers.dart'; import 'cpu_sparkline.dart'; import 'memory_usage.dart'; @@ -281,7 +282,13 @@ class _ResourcesDetailsState extends ConsumerState { ? null : 'Number of CPUs must be greater than 0' : null, - onSaved: (value) => ref.read(cpusProvider.notifier).set(value!), + onSaved: (value) { + if (value == cpus) return; + ref + .read(cpusProvider.notifier) + .set(value!) + .onError(ref.notifyError((error) => 'Failed to set CPUs: $error')); + }, ); final memoryInput = SpecInput( @@ -291,9 +298,13 @@ class _ResourcesDetailsState extends ConsumerState { helper: editing ? 'Default unit in Gigabytes' : null, enabled: editing, validator: memory != null ? memorySizeValidator : null, - onSaved: (value) => ref - .read(memoryProvider.notifier) - .set(double.tryParse(value!) != null ? '${value}GB' : value), + onSaved: (value) { + if (value == memory) return; + ref + .read(memoryProvider.notifier) + .set(double.tryParse(value!) != null ? '${value}GB' : value) + .onError(ref.notifyError((e) => 'Failed to set memory size: $e')); + }, ); final diskInput = SpecInput( @@ -314,9 +325,13 @@ class _ResourcesDetailsState extends ConsumerState { } } : null, - onSaved: (value) => ref - .read(diskProvider.notifier) - .set(double.tryParse(value!) != null ? '${value}GB' : value), + onSaved: (value) { + if (value == disk) return; + ref + .read(diskProvider.notifier) + .set(double.tryParse(value!) != null ? '${value}GB' : value) + .onError(ref.notifyError((e) => 'Failed to set disk size: $e')); + }, ); final saveButton = TextButton( @@ -413,7 +428,8 @@ class _BridgedDetailsState extends ConsumerState { initialValue: bridged ?? false, onSaved: (value) { if (value!) { - ref.read(bridgedProvider.notifier).set(value.toString()); + ref.read(bridgedProvider.notifier).set(value.toString()).onError( + ref.notifyError((e) => 'Failed to set bridged network: $e')); } setState(() => editing = false); }, @@ -508,87 +524,70 @@ class MountDetails extends ConsumerStatefulWidget { class _MountDetailsState extends ConsumerState { final formKey = GlobalKey(); bool editing = false; - final mountRequests = []; + final toMount = []; + final toUnmount = []; @override Widget build(BuildContext context) { final mounts = ref.watch(vmInfoProvider(widget.name).select((info) { return info.mountInfo.mountPaths.build(); })); - final stopped = ref.watch(vmInfoProvider(widget.name).select((info) { - return info.instanceStatus.status == Status.STOPPED; - })); - - if (!stopped) editing = false; - - final viewMountPoints = Column(children: [ - if (mounts.isNotEmpty) - const DefaultTextStyle( - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: Colors.black, - ), - child: Row(children: [ - Expanded(child: Text('SOURCE PATH')), - Expanded(child: Text('TARGET PATH')), - ]), - ), - for (final mount in mounts) ...[ - const Divider(height: 20), - Row(children: [ - Expanded( - child: Text(mount.sourcePath, style: const TextStyle(fontSize: 16)), - ), - Expanded( - child: Text(mount.targetPath, style: const TextStyle(fontSize: 16)), - ), - ]) - ], - ]); - final initialMountRequests = mounts.map((mount) { - return MountRequest( - mountMaps: mount.mountMaps, - sourcePath: mount.sourcePath, - targetPaths: [ - TargetPathInfo( - instanceName: widget.name, - targetPath: mount.targetPath, - ), - ], - ); - }); + final viewMountPoints = MountPointsView( + existingMounts: mounts, + editing: editing, + onSaved: (newToUnmount) => toUnmount.addAll(newToUnmount), + ); final editableMountPoints = MountPointList( - initialMountRequests: initialMountRequests.toBuiltList(), - onSaved: (newMountRequests) => mountRequests.addAll(newMountRequests), + showLabels: mounts.isEmpty, + onSaved: (newToMount) => toMount.addAll(newToMount), ); final saveButton = TextButton( onPressed: () async { - mountRequests.clear(); + toMount.clear(); + toUnmount.clear(); + if (!(formKey.currentState?.validate() ?? false)) return; formKey.currentState?.save(); setState(() => editing = false); final grpcClient = ref.read(grpcClientProvider); - await grpcClient.umount(widget.name); - for (final mountRequest in mountRequests) { + final notificationsNotifier = ref.read(notificationsProvider.notifier); + final umountOperations = >[]; + + for (final path in toUnmount) { + final operation = grpcClient.umount(widget.name, path); + notificationsNotifier.addOperation( + operation, + loading: 'Unmounting $path from ${widget.name}', + onSuccess: (_) => 'Unmounted $path from ${widget.name}', + onError: (error) { + return 'Failed to unmount $path from ${widget.name}: $error'; + }, + ); + umountOperations.add(operation); + } + + await Future.wait(umountOperations); + + for (final mountRequest in toMount) { + final description = + '${mountRequest.sourcePath} into ${widget.name}:${mountRequest.targetPaths.first.targetPath}'; mountRequest.targetPaths.first.instanceName = widget.name; - await grpcClient.mount(mountRequest); + notificationsNotifier.addOperation( + grpcClient.mount(mountRequest), + loading: 'Mounting $description', + onSuccess: (_) => 'Mounted $description', + onError: (error) => 'Failed to mount $description: $error', + ); } }, child: const Text('Save'), ); - final configureButton = TooltipVisibility( - visible: !stopped, - child: Tooltip( - message: 'Stop instance to configure', - child: OutlinedButton( - onPressed: stopped ? () => setState(() => editing = true) : null, - child: const Text('Configure'), - ), - ), + final configureButton = OutlinedButton( + onPressed: () => setState(() => editing = true), + child: const Text('Configure'), ); final cancelButton = OutlinedButton( @@ -613,12 +612,15 @@ class _MountDetailsState extends ConsumerState { const Spacer(), editing ? cancelButton : configureButton, ]), - editing ? editableMountPoints : viewMountPoints, - if (editing) + viewMountPoints, + if (editing) ...[ + const SizedBox(height: 20), + editableMountPoints, Padding( padding: const EdgeInsets.only(top: 16), child: saveButton, ), + ], ], ), ); diff --git a/src/client/gui/lib/vm_table/bulk_actions.dart b/src/client/gui/lib/vm_table/bulk_actions.dart index c1599c9671..b253b793d9 100644 --- a/src/client/gui/lib/vm_table/bulk_actions.dart +++ b/src/client/gui/lib/vm_table/bulk_actions.dart @@ -30,15 +30,16 @@ class BulkActionsBar extends ConsumerWidget { final object = selectedVms.length == 1 ? selectedVms.first : '${selectedVms.length} instances'; - final notification = OperationNotification( - text: '${action.continuousTense} $object', - future: function(selectedVms).then((_) { - return '${action.pastTense} $object'; - }).onError((_, __) { - throw 'Failed to ${action.name.toLowerCase()} $object'; - }), + + final notificationsNotifier = ref.read(notificationsProvider.notifier); + notificationsNotifier.addOperation( + function(selectedVms), + loading: '${action.continuousTense} $object', + onSuccess: (_) => '${action.pastTense} $object', + onError: (error) { + return 'Failed to ${action.name.toLowerCase()} $object: $error'; + }, ); - ref.read(notificationsProvider.notifier).add(notification); }; } diff --git a/src/client/gui/lib/vm_table/no_vms.dart b/src/client/gui/lib/vm_table/no_vms.dart index c1e6fa48ec..5c4581bebe 100644 --- a/src/client/gui/lib/vm_table/no_vms.dart +++ b/src/client/gui/lib/vm_table/no_vms.dart @@ -21,7 +21,7 @@ class NoVms extends ConsumerWidget { ); goToCatalogue() { - ref.read(sidebarKeyProvider.notifier).state = CatalogueScreen.sidebarKey; + ref.read(sidebarKeyProvider.notifier).set(CatalogueScreen.sidebarKey); } return Center( diff --git a/src/client/gui/lib/vm_table/table.dart b/src/client/gui/lib/vm_table/table.dart index ce54e9b105..743cc1d32a 100644 --- a/src/client/gui/lib/vm_table/table.dart +++ b/src/client/gui/lib/vm_table/table.dart @@ -175,13 +175,15 @@ class _TableState extends State> { ? const RemainingTableSpanExtent() : FixedTableSpanExtent(widget.headers[i].width), ), - cellBuilder: (_, v) => Container( - decoration: BoxDecoration( - border: Border( - bottom: v.row < cells.length - 1 ? borderSide : BorderSide.none, + cellBuilder: (_, v) => TableViewCell( + child: Container( + decoration: BoxDecoration( + border: Border( + bottom: v.row < cells.length - 1 ? borderSide : BorderSide.none, + ), ), + child: cells.elementAtOrNull(v.row)?.elementAtOrNull(v.column), ), - child: cells.elementAtOrNull(v.row)?.elementAtOrNull(v.column), ), ); diff --git a/src/client/gui/lib/vm_table/vm_table_headers.dart b/src/client/gui/lib/vm_table/vm_table_headers.dart index 357e48858b..e75e41d406 100644 --- a/src/client/gui/lib/vm_table/vm_table_headers.dart +++ b/src/client/gui/lib/vm_table/vm_table_headers.dart @@ -131,7 +131,7 @@ class VmNameLink extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - goToVm() => ref.read(sidebarKeyProvider.notifier).state = 'vm-$name'; + goToVm() => ref.read(sidebarKeyProvider.notifier).set('vm-$name'); return Tooltip( message: name, diff --git a/src/client/gui/lib/vm_table/vms.dart b/src/client/gui/lib/vm_table/vms.dart index 078d3ce037..aca1d73cc8 100644 --- a/src/client/gui/lib/vm_table/vms.dart +++ b/src/client/gui/lib/vm_table/vms.dart @@ -38,7 +38,7 @@ class Vms extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { goToCatalogue() { - ref.read(sidebarKeyProvider.notifier).state = CatalogueScreen.sidebarKey; + ref.read(sidebarKeyProvider.notifier).set(CatalogueScreen.sidebarKey); } final heading = Row(children: [ diff --git a/src/client/gui/linux/flutter/generated_plugin_registrant.cc b/src/client/gui/linux/flutter/generated_plugin_registrant.cc index 217ff21512..4ae48afdf4 100644 --- a/src/client/gui/linux/flutter/generated_plugin_registrant.cc +++ b/src/client/gui/linux/flutter/generated_plugin_registrant.cc @@ -6,16 +6,16 @@ #include "generated_plugin_registrant.h" -#include +#include #include #include #include #include void fl_register_plugins(FlPluginRegistry* registry) { - g_autoptr(FlPluginRegistrar) hotkey_manager_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "HotkeyManagerPlugin"); - hotkey_manager_plugin_register_with_registrar(hotkey_manager_registrar); + g_autoptr(FlPluginRegistrar) hotkey_manager_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "HotkeyManagerLinuxPlugin"); + hotkey_manager_linux_plugin_register_with_registrar(hotkey_manager_linux_registrar); g_autoptr(FlPluginRegistrar) screen_retriever_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverPlugin"); screen_retriever_plugin_register_with_registrar(screen_retriever_registrar); diff --git a/src/client/gui/linux/flutter/generated_plugins.cmake b/src/client/gui/linux/flutter/generated_plugins.cmake index 2776acf96f..5943d1e3c4 100644 --- a/src/client/gui/linux/flutter/generated_plugins.cmake +++ b/src/client/gui/linux/flutter/generated_plugins.cmake @@ -3,7 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST - hotkey_manager + hotkey_manager_linux screen_retriever tray_menu url_launcher_linux diff --git a/src/client/gui/macos/Flutter/GeneratedPluginRegistrant.swift b/src/client/gui/macos/Flutter/GeneratedPluginRegistrant.swift index 99832b0097..e45c10d390 100644 --- a/src/client/gui/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/src/client/gui/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,7 +5,7 @@ import FlutterMacOS import Foundation -import hotkey_manager +import hotkey_manager_macos import path_provider_foundation import screen_retriever import shared_preferences_foundation @@ -14,7 +14,7 @@ import url_launcher_macos import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - HotkeyManagerPlugin.register(with: registry.registrar(forPlugin: "HotkeyManagerPlugin")) + HotkeyManagerMacosPlugin.register(with: registry.registrar(forPlugin: "HotkeyManagerMacosPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) ScreenRetrieverPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) diff --git a/src/client/gui/macos/Runner.xcodeproj/project.pbxproj b/src/client/gui/macos/Runner.xcodeproj/project.pbxproj index ed8b30965c..4a93ea0f2f 100644 --- a/src/client/gui/macos/Runner.xcodeproj/project.pbxproj +++ b/src/client/gui/macos/Runner.xcodeproj/project.pbxproj @@ -54,7 +54,7 @@ /* Begin PBXFileReference section */ 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* multipass_gui.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = multipass_gui.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* Multipass.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Multipass.app; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -105,7 +105,7 @@ 33CC10EE2044A3C60003C045 /* Products */ = { isa = PBXGroup; children = ( - 33CC10ED2044A3C60003C045 /* multipass_gui.app */, + 33CC10ED2044A3C60003C045 /* Multipass.app */, ); name = Products; sourceTree = ""; @@ -172,7 +172,7 @@ ); name = Runner; productName = Runner; - productReference = 33CC10ED2044A3C60003C045 /* multipass_gui.app */; + productReference = 33CC10ED2044A3C60003C045 /* Multipass.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ diff --git a/src/client/gui/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/src/client/gui/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 16558f73cb..3feedfd02e 100644 --- a/src/client/gui/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/src/client/gui/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -15,7 +15,7 @@ @@ -31,7 +31,7 @@ @@ -54,7 +54,7 @@ @@ -71,7 +71,7 @@ diff --git a/src/client/gui/macos/Runner/Configs/AppInfo.xcconfig b/src/client/gui/macos/Runner/Configs/AppInfo.xcconfig index dec64a14fd..e4daa49eea 100644 --- a/src/client/gui/macos/Runner/Configs/AppInfo.xcconfig +++ b/src/client/gui/macos/Runner/Configs/AppInfo.xcconfig @@ -5,7 +5,7 @@ // 'flutter create' template. // The application's name. By default this is also the title of the Flutter window. -PRODUCT_NAME = multipass_gui +PRODUCT_NAME = Multipass // The application's bundle identifier PRODUCT_BUNDLE_IDENTIFIER = com.canonical.multipassGui diff --git a/src/client/gui/pubspec.lock b/src/client/gui/pubspec.lock index f946398bf5..c6aed2e50b 100644 --- a/src/client/gui/pubspec.lock +++ b/src/client/gui/pubspec.lock @@ -5,26 +5,26 @@ packages: dependency: transitive description: name: archive - sha256: "22600aa1e926be775fa5fe7e6894e7fb3df9efda8891c73f70fb3262399a432d" + sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d url: "https://pub.dev" source: hosted - version: "3.4.10" + version: "3.6.1" args: dependency: transitive description: name: args - sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 + sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.5.0" asn1lib: dependency: transitive description: name: asn1lib - sha256: c9c85fedbe2188b95133cbe960e16f5f448860f7133330e272edbbca5893ddc6 + sha256: "58082b3f0dca697204dbab0ef9ff208bfaea7767ea771076af9a343488428dda" url: "https://pub.dev" source: hosted - version: "1.5.2" + version: "1.5.3" async: dependency: "direct main" description: @@ -163,18 +163,18 @@ packages: dependency: "direct main" description: name: flutter_riverpod - sha256: da9591d1f8d5881628ccd5c25c40e74fc3eef50ba45e40c3905a06e1712412d5 + sha256: "0f1974eff5bbe774bf1d870e406fc6f29e3d6f1c46bd9c58e7172ff68a785d7d" url: "https://pub.dev" source: hosted - version: "2.4.9" + version: "2.5.1" flutter_svg: dependency: "direct main" description: name: flutter_svg - sha256: d39e7f95621fc84376bc0f7d504f05c3a41488c562f4a8ad410569127507402c + sha256: "7b4ca6cf3304575fe9c8ec64813c8d02ee41d2afe60bcfe0678bcb5375d596a2" url: "https://pub.dev" source: hosted - version: "2.0.9" + version: "2.0.10+1" flutter_web_plugins: dependency: transitive description: flutter @@ -188,14 +188,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "0c56c2c5d60d6dfaf9725f5ad4699f04749fb196ee5a70487a46ef184837ccf6" + url: "https://pub.dev" + source: hosted + version: "0.3.0+2" googleapis_auth: dependency: transitive description: name: googleapis_auth - sha256: af7c3a3edf9d0de2e1e0a77e994fae0a581c525fa7012af4fa0d4a52ed9484da + sha256: befd71383a955535060acde8792e7efc11d2fccd03dd1d3ec434e85b68775938 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.6.0" grpc: dependency: "direct main" description: @@ -208,10 +216,43 @@ packages: dependency: "direct main" description: name: hotkey_manager - sha256: "8aaa0aeaca7015b8c561a58d02eb7ebba95e93357fc9540398c5751ee24afd7c" + sha256: "06f0655b76c8dd322fb7101dc615afbdbf39c3d3414df9e059c33892104479cd" + url: "https://pub.dev" + source: hosted + version: "0.2.3" + hotkey_manager_linux: + dependency: "direct overridden" + description: + path: "packages/hotkey_manager_linux" + ref: no-cooked-accel + resolved-ref: "7e5a662615fbc9f077c1567fd7a66ec34ad52b5e" + url: "https://github.com/andrei-toterman/hotkey_manager.git" + source: git + version: "0.2.0" + hotkey_manager_macos: + dependency: transitive + description: + name: hotkey_manager_macos + sha256: "03b5967e64357b9ac05188ea4a5df6fe4ed4205762cb80aaccf8916ee1713c96" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + hotkey_manager_platform_interface: + dependency: transitive + description: + name: hotkey_manager_platform_interface + sha256: "98ffca25b8cc9081552902747b2942e3bc37855389a4218c9d50ca316b653b13" url: "https://pub.dev" source: hosted - version: "0.1.8" + version: "0.2.0" + hotkey_manager_windows: + dependency: transitive + description: + name: hotkey_manager_windows + sha256: "0d03ced9fe563ed0b68f0a0e1b22c9ffe26eb8053cb960e401f68a4f070e0117" + url: "https://pub.dev" + source: hosted + version: "0.2.0" http: dependency: transitive description: @@ -248,10 +289,18 @@ packages: dependency: transitive description: name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + url: "https://pub.dev" + source: hosted + version: "0.7.1" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" url: "https://pub.dev" source: hosted - version: "0.6.7" + version: "4.9.0" lints: dependency: transitive description: @@ -264,10 +313,10 @@ packages: dependency: "direct main" description: name: logger - sha256: "6bbb9d6f7056729537a4309bda2e74e18e5d9f14302489cc1e93f33b3fe32cac" + sha256: af05cc8714f356fd1f3888fb6741cbe9fbe25cdb6eedbab80e1a6db21047d4a4 url: "https://pub.dev" source: hosted - version: "2.0.2+1" + version: "2.3.0" matcher: dependency: transitive description: @@ -280,18 +329,18 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.5.0" meta: dependency: transitive description: name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.10.0" path: dependency: transitive description: @@ -312,26 +361,26 @@ packages: dependency: "direct main" description: name: path_provider - sha256: b27217933eeeba8ff24845c34003b003b2b22151de3c908d0e679e8fe1aa078b + sha256: c9e7d3a4cd1410877472158bee69963a4579f78b68c65a2b7d40d1a7a88bb161 url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: "477184d672607c0a3bf68fbbf601805f92ef79c82b64b4d6eb318cbca4c48668" + sha256: a248d8146ee5983446bf03ed5ea8f6533129a12b11f12057ad1b4a67a2b3b41d url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.2.4" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "5a7999be66e000916500be4f15a3633ebceb8302719b47b9cc49ce924125350f" + sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16 url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.0" path_provider_linux: dependency: transitive description: @@ -376,10 +425,10 @@ packages: dependency: transitive description: name: platform - sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" + sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.5" platform_info: dependency: transitive description: @@ -400,10 +449,10 @@ packages: dependency: transitive description: name: pointycastle - sha256: "43ac87de6e10afabc85c445745a7b799e04de84cebaa4fd7bf55a5e1e9604d29" + sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" url: "https://pub.dev" source: hosted - version: "3.7.4" + version: "3.9.1" protobuf: dependency: "direct main" description: @@ -424,18 +473,18 @@ packages: dependency: transitive description: name: riverpod - sha256: "942999ee48b899f8a46a860f1e13cee36f2f77609eb54c5b7a669bb20d550b11" + sha256: f21b32ffd26a36555e501b04f4a5dca43ed59e16343f1a30c13632b2351dfa4d url: "https://pub.dev" source: hosted - version: "2.4.9" + version: "2.5.1" rxdart: dependency: "direct main" description: name: rxdart - sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" url: "https://pub.dev" source: hosted - version: "0.27.7" + version: "0.28.0" screen_retriever: dependency: transitive description: @@ -448,26 +497,26 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: "81429e4481e1ccfb51ede496e916348668fd0921627779233bd24cc3ff6abd02" + sha256: d3bbe5553a986e83980916ded2f0b435ef2e1893dfaa29d5a7a790d0eca12180 url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.2.3" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "8568a389334b6e83415b6aae55378e158fbc2314e074983362d20c562780fb06" + sha256: "1ee8bf911094a1b592de7ab29add6f826a7331fb854273d55918693d5364a1f2" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "7708d83064f38060c7b39db12aefe449cb8cdc031d6062280087bc4cdb988f5c" + sha256: "0a8a893bf4fd1152f93fec03a415d11c27c74454d96e2318a7ac38dd18683ab7" url: "https://pub.dev" source: hosted - version: "2.3.5" + version: "2.4.0" shared_preferences_linux: dependency: transitive description: @@ -513,6 +562,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.0" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" stack_trace: dependency: transitive description: @@ -545,6 +602,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + synchronized: + dependency: "direct main" + description: + name: synchronized + sha256: "539ef412b170d65ecdafd780f924e5be3f60032a1128df156adad6c5b373d558" + url: "https://pub.dev" + source: hosted + version: "3.1.0+1" term_glyph: dependency: transitive description: @@ -574,10 +639,10 @@ packages: dependency: "direct main" description: name: two_dimensional_scrollables - sha256: "1dfd2d0768d29e2c65fed59c8e7e61fae6f8796d6c493583b30d91d05136c25e" + sha256: c972f327282149a9018eb629ea848fdbc1acf6eda0969d86317c97ce8e4efa78 url: "https://pub.dev" source: hosted - version: "0.0.6" + version: "0.2.1" typed_data: dependency: transitive description: @@ -586,30 +651,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.2" + uni_platform: + dependency: transitive + description: + name: uni_platform + sha256: e02213a7ee5352212412ca026afd41d269eb00d982faa552f419ffc2debfad84 + url: "https://pub.dev" + source: hosted + version: "0.1.3" url_launcher: dependency: "direct main" description: name: url_launcher - sha256: c512655380d241a337521703af62d2c122bf7b77a46ff7dd750092aa9433499c + sha256: "21b704ce5fa560ea9f3b525b43601c678728ba46725bab9b01187b4831377ed3" url: "https://pub.dev" source: hosted - version: "6.2.4" + version: "6.3.0" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: "507dc655b1d9cb5ebc756032eb785f114e415f91557b73bf60b7e201dfedeb2f" + sha256: "17cd5e205ea615e2c6ea7a77323a11712dffa0720a8a90540db57a01347f9ad9" url: "https://pub.dev" source: hosted - version: "6.2.2" + version: "6.3.2" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "75bb6fe3f60070407704282a2d295630cab232991eb52542b18347a8a941df03" + sha256: "7068716403343f6ba4969b4173cbf3b84fc768042124bc2c011e5d782b24fe89" url: "https://pub.dev" source: hosted - version: "6.2.4" + version: "6.3.0" url_launcher_linux: dependency: transitive description: @@ -622,18 +695,18 @@ packages: dependency: transitive description: name: url_launcher_macos - sha256: b7244901ea3cf489c5335bdacda07264a6e960b1c1b1a9f91e4bc371d9e68234 + sha256: "9a1a42d5d2d95400c795b2914c36fdcb525870c752569438e4ebb09a2b5d90de" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.2.0" url_launcher_platform_interface: dependency: transitive description: name: url_launcher_platform_interface - sha256: a932c3a8082e118f80a475ce692fde89dc20fddb24c57360b96bc56f7035de1f + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" url_launcher_web: dependency: transitive description: @@ -654,34 +727,34 @@ packages: dependency: transitive description: name: uuid - sha256: "648e103079f7c64a36dc7d39369cabb358d377078a051d6ae2ad3aa539519313" + sha256: "814e9e88f21a176ae1359149021870e87f7cddaf633ab678a5d2b0bff7fd1ba8" url: "https://pub.dev" source: hosted - version: "3.0.7" + version: "4.4.0" vector_graphics: dependency: transitive description: name: vector_graphics - sha256: "18f6690295af52d081f6808f2f7c69f0eed6d7e23a71539d75f4aeb8f0062172" + sha256: "32c3c684e02f9bc0afb0ae0aa653337a2fe022e8ab064bcd7ffda27a74e288e3" url: "https://pub.dev" source: hosted - version: "1.1.9+2" + version: "1.1.11+1" vector_graphics_codec: dependency: transitive description: name: vector_graphics_codec - sha256: "531d20465c10dfac7f5cd90b60bbe4dd9921f1ec4ca54c83ebb176dbacb7bb2d" + sha256: c86987475f162fadff579e7320c7ddda04cd2fdeffbe1129227a85d9ac9e03da url: "https://pub.dev" source: hosted - version: "1.1.9+2" + version: "1.1.11+1" vector_graphics_compiler: dependency: transitive description: name: vector_graphics_compiler - sha256: "03012b0a33775c5530576b70240308080e1d5050f0faf000118c20e6463bc0ad" + sha256: "12faff3f73b1741a36ca7e31b292ddeb629af819ca9efe9953b70bd63fc8cd81" url: "https://pub.dev" source: hosted - version: "1.1.9+2" + version: "1.1.11+1" vector_math: dependency: transitive description: @@ -699,7 +772,7 @@ packages: source: hosted version: "0.3.0" win32: - dependency: transitive + dependency: "direct main" description: name: win32 sha256: "464f5674532865248444b4c3daca12bd9bf2d7c47f759ce2617986e7229494a8" @@ -710,10 +783,10 @@ packages: dependency: "direct main" description: name: window_manager - sha256: b3c895bdf936c77b83c5254bec2e6b3f066710c1f89c38b20b8acc382b525494 + sha256: "8699323b30da4cdbe2aa2e7c9de567a6abd8a97d9a5c850a3c86dcd0b34bbfbf" url: "https://pub.dev" source: hosted - version: "0.3.8" + version: "0.3.9" xdg_directories: dependency: transitive description: @@ -734,10 +807,18 @@ packages: dependency: "direct main" description: name: xterm - sha256: "6a02b15d03152b8186e12790902ff28c8a932fc441e89fa7255a7491661a8e69" + sha256: "88a6e34caa4474c92b97d4ab5dad4d1dcf47fc06063776f9533a00124063d33c" + url: "https://pub.dev" + source: hosted + version: "3.6.1-pre" + zmodem: + dependency: transitive + description: + name: zmodem + sha256: "3b7e5b29f3a7d8aee472029b05165a68438eff2f3f7766edf13daba1e297adbf" url: "https://pub.dev" source: hosted - version: "3.5.0" + version: "0.0.6" sdks: - dart: ">=3.3.0-0 <4.0.0" - flutter: ">=3.16.0" + dart: ">=3.2.3 <4.0.0" + flutter: ">=3.16.6" diff --git a/src/client/gui/pubspec.yaml b/src/client/gui/pubspec.yaml index a49d08fba6..582860a832 100644 --- a/src/client/gui/pubspec.yaml +++ b/src/client/gui/pubspec.yaml @@ -2,7 +2,7 @@ name: multipass_gui description: A new Flutter project. publish_to: 'none' -version: 1.0.0+1 +version: 1.14.0 environment: sdk: '>=3.0.3 <4.0.0' @@ -11,34 +11,43 @@ dependencies: async: ^2.11.0 basics: ^0.10.0 built_collection: ^5.1.1 - collection: ^1.17.1 + collection: ^1.18.0 dartssh2: git: url: https://github.com/andrei-toterman/dartssh2.git ref: use-raw-socket - ffi: ^2.0.2 + ffi: ^2.1.0 fl_chart: ^0.68.0 flutter: sdk: flutter - flutter_riverpod: ^2.3.6 - flutter_svg: ^2.0.7 + flutter_riverpod: ^2.5.1 + flutter_svg: ^2.0.10+1 fpdart: ^1.1.0 - grpc: ^3.1.0 - hotkey_manager: ^0.1.8 + grpc: ^3.2.4 + hotkey_manager: ^0.2.3 intl: ^0.19.0 - logger: ^2.0.2+1 - path_provider: ^2.1.0 + logger: ^2.3.0 + path_provider: ^2.1.3 protobuf: ^3.1.0 - rxdart: ^0.27.7 - shared_preferences: ^2.2.2 + rxdart: ^0.28.0 + shared_preferences: ^2.2.3 + synchronized: ^3.1.0+1 tray_menu: git: url: https://github.com/andrei-toterman/tray_menu.git ref: 7c1394c - two_dimensional_scrollables: ^0.0.2 - url_launcher: ^6.2.3 - window_manager: ^0.3.5 - xterm: ^3.5.0 + two_dimensional_scrollables: ^0.2.1 + url_launcher: ^6.3.0 + win32: ^5.2.0 + window_manager: ^0.3.9 + xterm: ^3.6.0 + +dependency_overrides: + hotkey_manager_linux: + git: + url: https://github.com/andrei-toterman/hotkey_manager.git + ref: no-cooked-accel + path: packages/hotkey_manager_linux dev_dependencies: flutter_lints: ^4.0.0 diff --git a/src/client/gui/windows/CMakeLists.txt b/src/client/gui/windows/CMakeLists.txt index d55cd38a4f..09a4082e62 100644 --- a/src/client/gui/windows/CMakeLists.txt +++ b/src/client/gui/windows/CMakeLists.txt @@ -4,7 +4,7 @@ project(multipass_gui LANGUAGES CXX) # The name of the executable created for the application. Change this to change # the on-disk name of your application. -set(BINARY_NAME "multipass_gui") +set(BINARY_NAME "multipass.gui") # Explicitly opt in to modern CMake behaviors to avoid warnings with recent # versions of CMake. diff --git a/src/client/gui/windows/flutter/generated_plugin_registrant.cc b/src/client/gui/windows/flutter/generated_plugin_registrant.cc index 5ac88b2fb9..b069f015a8 100644 --- a/src/client/gui/windows/flutter/generated_plugin_registrant.cc +++ b/src/client/gui/windows/flutter/generated_plugin_registrant.cc @@ -6,15 +6,15 @@ #include "generated_plugin_registrant.h" -#include +#include #include #include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { - HotkeyManagerPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("HotkeyManagerPlugin")); + HotkeyManagerWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("HotkeyManagerWindowsPluginCApi")); ScreenRetrieverPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("ScreenRetrieverPlugin")); TrayMenuPluginCApiRegisterWithRegistrar( diff --git a/src/client/gui/windows/flutter/generated_plugins.cmake b/src/client/gui/windows/flutter/generated_plugins.cmake index c0afcd5740..efa134fdbb 100644 --- a/src/client/gui/windows/flutter/generated_plugins.cmake +++ b/src/client/gui/windows/flutter/generated_plugins.cmake @@ -3,7 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST - hotkey_manager + hotkey_manager_windows screen_retriever tray_menu url_launcher_windows diff --git a/src/client/gui/windows/runner/Runner.rc b/src/client/gui/windows/runner/Runner.rc index c8cf89083d..e08e720cf7 100644 --- a/src/client/gui/windows/runner/Runner.rc +++ b/src/client/gui/windows/runner/Runner.rc @@ -92,9 +92,9 @@ BEGIN VALUE "CompanyName", "com.canonical" "\0" VALUE "FileDescription", "Multipass GUI" "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" - VALUE "InternalName", "multipass_gui" "\0" + VALUE "InternalName", "multipass.gui" "\0" VALUE "LegalCopyright", "Copyright (C) Canonical, Ltd. All rights reserved." "\0" - VALUE "OriginalFilename", "multipass_gui.exe" "\0" + VALUE "OriginalFilename", "multipass.gui.exe" "\0" VALUE "ProductName", "Multipass GUI" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0" END