Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Show in-app notification if an update is available #3621

Merged
merged 12 commits into from
Sep 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/client/cli/cmd/info.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@
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());

Check warning on line 39 in src/client/cli/cmd/info.cpp

View check run for this annotation

Codecov / codecov/patch

src/client/cli/cmd/info.cpp#L39

Added line #L39 was not covered by tests

return ReturnCode::Ok;
};

Expand Down
34 changes: 23 additions & 11 deletions src/client/gui/lib/grpc_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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<RpcMessage>) logGrpc(RpcMessage request) {
return (notification) {
switch (notification.kind) {
Expand Down Expand Up @@ -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) {
Expand All @@ -84,6 +101,7 @@ class GrpcClient {
logger.i('Sent ${request.repr}');
return _client
.start(Stream.value(request))
.doOnData(checkForUpdate)
.doOnEach(logGrpc(request))
.firstOrNull;
}
Expand Down Expand Up @@ -117,6 +135,7 @@ class GrpcClient {
logger.i('Sent ${request.repr}');
return _client
.restart(Stream.value(request))
.doOnData(checkForUpdate)
.doOnEach(logGrpc(request))
.firstOrNull;
}
Expand Down Expand Up @@ -167,6 +186,7 @@ class GrpcClient {
);
return _client
.info(Stream.value(request))
.doOnData(checkForUpdate)
.last
.then((r) => r.details.toList());
}
Expand Down Expand Up @@ -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);
Expand All @@ -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> updateInfo() {
final request = VersionRequest();
logger.i('Sent ${request.repr}');
return _client
.version(Stream.value(request))
.doOnEach(logGrpc(request))
.last
.then((reply) => reply.updateInfo);
}

Future<String> get(String key) {
final request = GetRequest(key: key);
logger.i('Sent ${request.repr}');
Expand Down
2 changes: 1 addition & 1 deletion src/client/gui/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}),
Expand Down
15 changes: 8 additions & 7 deletions src/client/gui/lib/notifications/notification_entries.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ 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;
final double barFullness;

const SimpleNotification({
super.key,
required this.text,
required this.child,
required this.icon,
required this.barColor,
this.closeable = true,
Expand Down Expand Up @@ -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),
]),
),
),
Expand Down Expand Up @@ -113,21 +113,22 @@ class _TimeoutNotificationState extends State<TimeoutNotification>
child: AnimatedBuilder(
animation: timeoutController,
builder: (_, __) => SimpleNotification(
text: widget.text,
icon: widget.icon,
barColor: widget.barColor,
barFullness: 1.0 - timeoutController.value,
child: Text(widget.text),
),
),
);
}
}

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),
);
Expand Down Expand Up @@ -167,14 +168,14 @@ class OperationNotification extends StatelessWidget {
}

return SimpleNotification(
text: text,
barColor: Colors.blue,
closeable: false,
icon: const CircularProgressIndicator(
color: Colors.blue,
strokeAlign: -2,
strokeWidth: 3.5,
),
child: Text(text),
);
},
);
Expand Down
2 changes: 2 additions & 0 deletions src/client/gui/lib/providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import 'logger.dart';

export 'grpc_client.dart';

late final ProviderContainer providerContainer;

final grpcClientProvider = Provider((_) {
final address = getServerAddress();
final certPair = getCertPair();
Expand Down
119 changes: 32 additions & 87 deletions src/client/gui/lib/settings/general_settings.dart
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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',
},
),
]);
}
}
Loading
Loading