diff --git a/src/client/cli/cmd/info.cpp b/src/client/cli/cmd/info.cpp index 5594d30fee..18ad735902 100644 --- a/src/client/cli/cmd/info.cpp +++ b/src/client/cli/cmd/info.cpp @@ -35,6 +35,9 @@ mp::ReturnCode cmd::Info::run(mp::ArgParser* parser) auto on_success = [this](mp::InfoReply& reply) { cout << chosen_formatter->format(reply); + if (term->is_live() && update_available(reply.update_info())) + cout << update_notice(reply.update_info()); + return ReturnCode::Ok; }; diff --git a/src/client/gui/lib/grpc_client.dart b/src/client/gui/lib/grpc_client.dart index 81ab2f1c6d..226f5ff1ab 100644 --- a/src/client/gui/lib/grpc_client.dart +++ b/src/client/gui/lib/grpc_client.dart @@ -6,8 +6,9 @@ import 'package:grpc/grpc.dart'; import 'package:protobuf/protobuf.dart' hide RpcClient; import 'package:rxdart/rxdart.dart'; -import 'generated/multipass.pbgrpc.dart'; import 'logger.dart'; +import 'providers.dart'; +import 'update_available.dart'; export 'generated/multipass.pbgrpc.dart'; @@ -21,6 +22,21 @@ extension on RpcMessage { String get repr => '$runtimeType${toProto3Json()}'; } +void checkForUpdate(RpcMessage message) { + final updateInfo = switch (message) { + LaunchReply launchReply => launchReply.updateInfo, + InfoReply infoReply => infoReply.updateInfo, + ListReply listReply => listReply.updateInfo, + NetworksReply networksReply => networksReply.updateInfo, + StartReply startReply => startReply.updateInfo, + RestartReply restartReply => restartReply.updateInfo, + VersionReply versionReply => versionReply.updateInfo, + _ => UpdateInfo(), + }; + + providerContainer.read(updateProvider.notifier).set(updateInfo); +} + void Function(StreamNotification) logGrpc(RpcMessage request) { return (notification) { switch (notification.kind) { @@ -62,6 +78,7 @@ class GrpcClient { logger.i('Sent ${request.repr}'); yield* _client .launch(Stream.value(request)) + .doOnData(checkForUpdate) .doOnEach(logGrpc(request)) .map(Either.left); for (final mountRequest in mountRequests) { @@ -84,6 +101,7 @@ class GrpcClient { logger.i('Sent ${request.repr}'); return _client .start(Stream.value(request)) + .doOnData(checkForUpdate) .doOnEach(logGrpc(request)) .firstOrNull; } @@ -117,6 +135,7 @@ class GrpcClient { logger.i('Sent ${request.repr}'); return _client .restart(Stream.value(request)) + .doOnData(checkForUpdate) .doOnEach(logGrpc(request)) .firstOrNull; } @@ -167,6 +186,7 @@ class GrpcClient { ); return _client .info(Stream.value(request)) + .doOnData(checkForUpdate) .last .then((r) => r.details.toList()); } @@ -204,6 +224,7 @@ class GrpcClient { logger.i('Sent ${request.repr}'); return _client .networks(Stream.value(request)) + .doOnData(checkForUpdate) .doOnEach(logGrpc(request)) .last .then((r) => r.interfaces); @@ -214,21 +235,12 @@ class GrpcClient { logger.i('Sent ${request.repr}'); return _client .version(Stream.value(request)) + .doOnData(checkForUpdate) .doOnEach(logGrpc(request)) .last .then((reply) => reply.version); } - Future updateInfo() { - final request = VersionRequest(); - logger.i('Sent ${request.repr}'); - return _client - .version(Stream.value(request)) - .doOnEach(logGrpc(request)) - .last - .then((reply) => reply.updateInfo); - } - Future get(String key) { final request = GetRequest(key: key); logger.i('Sent ${request.repr}'); diff --git a/src/client/gui/lib/main.dart b/src/client/gui/lib/main.dart index da5807d68b..c07ca9f99d 100644 --- a/src/client/gui/lib/main.dart +++ b/src/client/gui/lib/main.dart @@ -38,7 +38,7 @@ void main() async { await hotKeyManager.unregisterAll(); final sharedPreferences = await SharedPreferences.getInstance(); - final providerContainer = ProviderContainer(overrides: [ + providerContainer = ProviderContainer(overrides: [ guiSettingProvider.overrideWith(() { return GuiSettingNotifier(sharedPreferences); }), diff --git a/src/client/gui/lib/notifications/notification_entries.dart b/src/client/gui/lib/notifications/notification_entries.dart index 468b057b40..7a9c511403 100644 --- a/src/client/gui/lib/notifications/notification_entries.dart +++ b/src/client/gui/lib/notifications/notification_entries.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'notifications_list.dart'; class SimpleNotification extends StatelessWidget { - final String text; + final Widget child; final Widget icon; final Color barColor; final bool closeable; @@ -11,7 +11,7 @@ class SimpleNotification extends StatelessWidget { const SimpleNotification({ super.key, - required this.text, + required this.child, required this.icon, required this.barColor, this.closeable = true, @@ -40,7 +40,7 @@ class SimpleNotification extends StatelessWidget { child: FittedBox(fit: BoxFit.fill, child: icon), ), const SizedBox(width: 8), - Expanded(child: Text(text)), + Expanded(child: child), ]), ), ), @@ -113,10 +113,10 @@ class _TimeoutNotificationState extends State child: AnimatedBuilder( animation: timeoutController, builder: (_, __) => SimpleNotification( - text: widget.text, icon: widget.icon, barColor: widget.barColor, barFullness: 1.0 - timeoutController.value, + child: Text(widget.text), ), ), ); @@ -124,10 +124,11 @@ class _TimeoutNotificationState extends State } class ErrorNotification extends SimpleNotification { - const ErrorNotification({ + ErrorNotification({ super.key, - required super.text, + required String text, }) : super( + child: Text(text), barColor: Colors.red, icon: const Icon(Icons.cancel_outlined, color: Colors.red), ); @@ -167,7 +168,6 @@ class OperationNotification extends StatelessWidget { } return SimpleNotification( - text: text, barColor: Colors.blue, closeable: false, icon: const CircularProgressIndicator( @@ -175,6 +175,7 @@ class OperationNotification extends StatelessWidget { strokeAlign: -2, strokeWidth: 3.5, ), + child: Text(text), ); }, ); diff --git a/src/client/gui/lib/providers.dart b/src/client/gui/lib/providers.dart index 23f8fe0817..3d2cf6d420 100644 --- a/src/client/gui/lib/providers.dart +++ b/src/client/gui/lib/providers.dart @@ -14,6 +14,8 @@ import 'logger.dart'; export 'grpc_client.dart'; +late final ProviderContainer providerContainer; + final grpcClientProvider = Provider((_) { final address = getServerAddress(); final certPair = getCertPair(); diff --git a/src/client/gui/lib/settings/general_settings.dart b/src/client/gui/lib/settings/general_settings.dart index e5e0b81310..e0a100a297 100644 --- a/src/client/gui/lib/settings/general_settings.dart +++ b/src/client/gui/lib/settings/general_settings.dart @@ -1,24 +1,14 @@ import 'package:basics/basics.dart'; import 'package:flutter/material.dart' hide Switch; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:url_launcher/url_launcher.dart'; import '../dropdown.dart'; -import '../notifications/notifications_provider.dart'; +import '../notifications.dart'; import '../providers.dart'; import '../switch.dart'; +import '../update_available.dart'; import 'autostart_notifiers.dart'; -final updateProvider = Provider.autoDispose((ref) { - ref - .watch(grpcClientProvider) - .updateInfo() - .then((value) => ref.state = value) - .ignore(); - return UpdateInfo(); -}); - final onAppCloseProvider = guiSettingProvider(onAppCloseKey); class GeneralSettings extends ConsumerWidget { @@ -30,85 +20,40 @@ class GeneralSettings extends ConsumerWidget { final autostart = ref.watch(autostartProvider).valueOrNull ?? false; final onAppClose = ref.watch(onAppCloseProvider); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'General', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 20), - if (update.version.isNotBlank) UpdateAvailable(update), - Switch( - label: 'Open the Multipass GUI on startup', - value: autostart, - trailingSwitch: true, - size: 30, - onChanged: (value) { - ref - .read(autostartProvider.notifier) - .set(value) - .onError(ref.notifyError((e) => 'Failed to set autostart: $e')); - }, - ), + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text( + 'General', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 20), + if (update.version.isNotBlank) ...[ + UpdateAvailable(update), const SizedBox(height: 20), - Dropdown( - label: 'On close of application', - width: 260, - value: onAppClose ?? 'ask', - onChanged: (value) => - ref.read(onAppCloseProvider.notifier).set(value!), - items: const { - 'ask': 'Ask about running instances', - 'stop': 'Stop running instances', - 'nothing': 'Do not stop running instances', - }, - ), ], - ); - } -} - -class UpdateAvailable extends StatelessWidget { - final UpdateInfo updateInfo; - - const UpdateAvailable(this.updateInfo, {super.key}); - - static final installUrl = Uri.parse('https://multipass.run/install'); - - static void launchInstallUrl() => launchUrl(installUrl); - - @override - Widget build(BuildContext context) { - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Update available', style: TextStyle(fontSize: 16)), - const SizedBox(height: 8), - Container( - color: const Color(0xffF7F7F7), - padding: const EdgeInsets.all(12), - width: 480, - child: Row(children: [ - Container( - alignment: Alignment.center, - color: const Color(0xffE95420), - height: 48, - width: 48, - child: SvgPicture.asset('assets/multipass.svg', width: 30), - ), - const SizedBox(width: 12), - Text( - 'Multipass ${updateInfo.version}\nis available', - style: const TextStyle(fontSize: 16), - ), - const SizedBox(width: 12), - const Spacer(), - const TextButton( - onPressed: launchInstallUrl, - child: Text('Upgrade now'), - ), - ]), + Switch( + label: 'Open the Multipass GUI on startup', + value: autostart, + trailingSwitch: true, + size: 30, + onChanged: (value) { + ref + .read(autostartProvider.notifier) + .set(value) + .onError(ref.notifyError((e) => 'Failed to set autostart: $e')); + }, ), const SizedBox(height: 20), + Dropdown( + label: 'On close of application', + width: 260, + value: onAppClose ?? 'ask', + onChanged: (value) => ref.read(onAppCloseProvider.notifier).set(value!), + items: const { + 'ask': 'Ask about running instances', + 'stop': 'Stop running instances', + 'nothing': 'Do not stop running instances', + }, + ), ]); } } diff --git a/src/client/gui/lib/update_available.dart b/src/client/gui/lib/update_available.dart new file mode 100644 index 0000000000..3710dc6515 --- /dev/null +++ b/src/client/gui/lib/update_available.dart @@ -0,0 +1,113 @@ +import 'package:basics/basics.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'notifications/notification_entries.dart'; +import 'notifications/notifications_list.dart'; +import 'notifications/notifications_provider.dart'; +import 'providers.dart'; + +class UpdateNotifier extends Notifier { + @override + UpdateInfo build() => UpdateInfo(); + + void set(UpdateInfo updateInfo) { + if (updateInfo.version.isBlank) return; + final updateNotificationExists = ref.read(notificationsProvider).any((n) { + return n is UpdateAvailableNotification && n.updateInfo == updateInfo; + }); + if (updateNotificationExists) return; + ref + .read(notificationsProvider.notifier) + .add(UpdateAvailableNotification(updateInfo)); + state = updateInfo; + } + + @override + bool updateShouldNotify(UpdateInfo previous, UpdateInfo next) { + return previous != next; + } +} + +final updateProvider = NotifierProvider( + UpdateNotifier.new, +); + +const _color = Color(0xffE95420); +final installUrl = Uri.parse('https://multipass.run/install'); + +Future launchInstallUrl() => launchUrl(installUrl); + +class UpdateAvailable extends StatelessWidget { + final UpdateInfo updateInfo; + + const UpdateAvailable(this.updateInfo, {super.key}); + + @override + Widget build(BuildContext context) { + final icon = Container( + alignment: Alignment.center, + color: _color, + height: 40, + width: 40, + child: SvgPicture.asset('assets/multipass.svg', width: 25), + ); + + final text = Text( + 'Multipass ${updateInfo.version} is available', + style: const TextStyle(fontSize: 16), + ); + + const button = TextButton( + onPressed: launchInstallUrl, + child: Text('Upgrade now'), + ); + + return Container( + color: const Color(0xffF7F7F7), + padding: const EdgeInsets.all(12), + child: Row(children: [ + icon, + const SizedBox(width: 12), + text, + const Spacer(), + button, + ]), + ); + } +} + +class UpdateAvailableNotification extends StatelessWidget { + final UpdateInfo updateInfo; + + const UpdateAvailableNotification(this.updateInfo, {super.key}); + + @override + Widget build(BuildContext context) { + return SimpleNotification( + barColor: _color, + icon: SvgPicture.asset( + 'assets/multipass.svg', + width: 30, + colorFilter: const ColorFilter.mode(_color, BlendMode.srcIn), + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text( + 'Multipass ${updateInfo.version} is available', + style: const TextStyle(fontSize: 16), + ), + const SizedBox(height: 12), + TextButton( + onPressed: () async { + await launchInstallUrl(); + if (!context.mounted) return; + Actions.maybeInvoke(context, const CloseNotificationIntent()); + }, + child: const Text('Upgrade now', style: TextStyle(fontSize: 14)), + ), + ]), + ); + } +} diff --git a/src/daemon/daemon.cpp b/src/daemon/daemon.cpp index a38552498e..dac8ec72b3 100644 --- a/src/daemon/daemon.cpp +++ b/src/daemon/daemon.cpp @@ -1665,6 +1665,7 @@ try // clang-format on mpl::ClientLogger logger{mpl::level_from(request->verbosity_level()), *config->logger, server}; InfoReply response; + config->update_prompt->populate_if_time_to_show(response.mutable_update_info()); InstanceSnapshotsMap instance_snapshots_map; bool have_mounts = false; bool deleted = false; diff --git a/src/rpc/multipass.proto b/src/rpc/multipass.proto index 330b5671c2..e6666ad820 100644 --- a/src/rpc/multipass.proto +++ b/src/rpc/multipass.proto @@ -254,6 +254,7 @@ message InfoReply { repeated DetailedInfoItem details = 1; bool snapshots = 2; // useful to determine what entity (instance/snapshot) was absent when details are empty string log_line = 3; + UpdateInfo update_info = 4; } message ListRequest {