Pause polling and show a banner when the backend is unreachable

Adds a centralized BackendReachability singleton fed by a Dio
interceptor (connection-level failures only) and connectivity_plus.
PolledValue subscribes to it and pauses on offline / reloads
immediately on reconnect, replacing the previous per-card error
cascade. MainShell renders a global OfflineBanner with Retry and
Settings actions; cards downgrade error→stale when offline so the
banner is the only red element.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-05-31 18:21:55 -04:00
co-authored by Claude Sonnet 4.6
parent 8be65f3ffa
commit b5da90be59
15 changed files with 388 additions and 47 deletions
+23 -3
View File
@@ -8,6 +8,7 @@ import 'devices/device_list.dart';
import 'home/home_screen.dart';
import 'status/status_screen.dart';
import 'utils/pref_utils.dart';
import 'widgets/offline_banner.dart';
// M3 window size class breakpoints
const _mediumBreakpoint = 600.0;
@@ -109,10 +110,20 @@ class MainShell extends StatelessWidget {
if (width < _mediumBreakpoint) {
return Scaffold(
appBar: _buildAppBar(context),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
body: Column(
children: [
const OfflineBanner(),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: child,
),
),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: selectedIndex,
onDestinationSelected: (index) =>
@@ -156,13 +167,22 @@ class MainShell extends StatelessWidget {
),
Expanded(
child: Container(
padding: const EdgeInsets.all(20),
color: Theme.of(context).colorScheme.surface,
child: Column(
children: [
const OfflineBanner(),
Expanded(
child: Padding(
padding: const EdgeInsets.all(20),
child: child,
),
),
],
),
),
),
],
),
);
},
);
@@ -0,0 +1,96 @@
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'oott_api.dart';
class BackendReachability extends ChangeNotifier {
BackendReachability._internal() {
_subscription = Connectivity().onConnectivityChanged.listen(
_handleConnectivityChange,
);
Connectivity().checkConnectivity().then(_handleConnectivityChange);
}
static final BackendReachability instance = BackendReachability._internal();
StreamSubscription<List<ConnectivityResult>>? _subscription;
Future<void> Function()? _prober;
bool _deviceHasNetwork = true;
bool _isBackendReachable = true;
DateTime? _lastSuccessAt;
DateTime? _lastFailureAt;
String? _lastErrorMessage;
bool _probing = false;
bool get deviceHasNetwork => _deviceHasNetwork;
bool get isBackendReachable => _isBackendReachable;
bool get isOnline => _deviceHasNetwork && _isBackendReachable;
bool get isProbing => _probing;
DateTime? get lastSuccessAt => _lastSuccessAt;
DateTime? get lastFailureAt => _lastFailureAt;
String? get lastErrorMessage => _lastErrorMessage;
void setProber(Future<void> Function() prober) {
_prober = prober;
}
void recordSuccess() {
_lastSuccessAt = DateTime.now();
_lastErrorMessage = null;
final wasReachable = _isBackendReachable;
_isBackendReachable = true;
if (!wasReachable) notifyListeners();
}
void recordFailure(DioException error) {
if (!_isConnectionLevel(error)) return;
_lastFailureAt = DateTime.now();
_lastErrorMessage = dioErrorToUserMessage(error);
final wasReachable = _isBackendReachable;
_isBackendReachable = false;
if (wasReachable) notifyListeners();
}
Future<void> probe() async {
final prober = _prober;
if (prober == null || _probing) return;
_probing = true;
notifyListeners();
try {
await prober();
} catch (_) {
// Interceptor records the outcome; nothing else to do here.
} finally {
_probing = false;
notifyListeners();
}
}
void _handleConnectivityChange(List<ConnectivityResult> results) {
final hasNetwork = results.any((r) => r != ConnectivityResult.none);
if (hasNetwork == _deviceHasNetwork) return;
_deviceHasNetwork = hasNetwork;
notifyListeners();
if (hasNetwork) {
unawaited(probe());
}
}
bool _isConnectionLevel(DioException error) {
if (error.type == DioExceptionType.cancel) return false;
// Anything without a response from the server is a connection-level
// failure (timeouts, connection refused, DNS, TLS, web fetch errors
// surfaced as DioExceptionType.unknown, etc.).
return error.response == null;
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
}
+14
View File
@@ -4,6 +4,7 @@ import 'package:dio/dio.dart';
import 'package:dio_smart_retry/dio_smart_retry.dart';
import 'package:encrypter/encrypter/xor.dart';
import 'package:flutter/foundation.dart';
import 'package:frontend/utils/backend_reachability.dart';
import 'package:frontend/utils/pref_utils.dart';
import '../model/arp_scanner_status.dart';
import '../model/mdns_scanner_status.dart';
@@ -36,6 +37,17 @@ RetryInterceptor _buildRetryInterceptor(Dio dio) => RetryInterceptor(
},
);
InterceptorsWrapper _buildReachabilityInterceptor() => InterceptorsWrapper(
onResponse: (response, handler) {
BackendReachability.instance.recordSuccess();
handler.next(response);
},
onError: (error, handler) {
BackendReachability.instance.recordFailure(error);
handler.next(error);
},
);
LogInterceptor _buildLogInterceptor() => LogInterceptor(
request: true,
requestHeader: false,
@@ -106,9 +118,11 @@ class BackendAPI {
),
);
_dio.interceptors.add(_buildRetryInterceptor(_dio));
_dio.interceptors.add(_buildReachabilityInterceptor());
if (kDebugMode) {
_dio.interceptors.add(_buildLogInterceptor());
}
BackendReachability.instance.setProber(() => _dio.get('/test'));
}
// Returns null if the test was successful, and a String with a message about the issue if not
+59 -6
View File
@@ -3,10 +3,27 @@ import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'backend_reachability.dart';
import 'oott_api.dart';
enum PolledFreshness { initialLoading, fresh, stale, error }
enum PolledPauseReason { manual, unreachable }
/// Returns the freshness of [polled] downgraded to [PolledFreshness.stale]
/// when the backend is known to be unreachable and a previous value exists.
/// This avoids showing per-card error chrome while the global offline banner
/// is already communicating the connectivity issue.
PolledFreshness effectiveFreshness(PolledValue polled) {
final base = polled.freshness;
if (base == PolledFreshness.error &&
polled.value != null &&
!BackendReachability.instance.isOnline) {
return PolledFreshness.stale;
}
return base;
}
class PolledValue<T> extends ChangeNotifier {
PolledValue({
required Future<T> Function({CancelToken? cancelToken}) fetch,
@@ -15,13 +32,23 @@ class PolledValue<T> extends ChangeNotifier {
}) : _fetch = fetch,
_pollInterval = pollInterval,
_staleErrorAfter = staleErrorAfter {
_reachability = BackendReachability.instance;
_reachability.addListener(_onReachabilityChanged);
if (!_reachability.isOnline) {
_pauseReasons.add(PolledPauseReason.unreachable);
}
if (_pauseReasons.isEmpty) {
_load();
_pollTimer = Timer.periodic(pollInterval, (_) => _load());
_startPolling();
}
}
final Future<T> Function({CancelToken? cancelToken}) _fetch;
final Duration _pollInterval;
final Duration _staleErrorAfter;
final Set<PolledPauseReason> _pauseReasons = {};
late final BackendReachability _reachability;
Timer? _pollTimer;
CancelToken? _cancelToken;
bool _disposed = false;
@@ -68,23 +95,49 @@ class PolledValue<T> extends ChangeNotifier {
}
}
void pause() {
void _startPolling() {
_pollTimer ??= Timer.periodic(_pollInterval, (_) => _load());
}
void _stopPolling() {
_pollTimer?.cancel();
_pollTimer = null;
_cancelToken?.cancel();
}
void resume() {
void pauseFor(PolledPauseReason reason) {
final added = _pauseReasons.add(reason);
if (added && _pauseReasons.length == 1) {
_stopPolling();
}
}
void resumeFor(PolledPauseReason reason) {
if (_disposed) return;
final removed = _pauseReasons.remove(reason);
if (removed && _pauseReasons.isEmpty) {
_load();
_pollTimer = Timer.periodic(_pollInterval, (_) => _load());
_startPolling();
}
}
void pause() => pauseFor(PolledPauseReason.manual);
void resume() => resumeFor(PolledPauseReason.manual);
void _onReachabilityChanged() {
if (_reachability.isOnline) {
resumeFor(PolledPauseReason.unreachable);
} else {
pauseFor(PolledPauseReason.unreachable);
}
}
@override
void dispose() {
_disposed = true;
_pollTimer?.cancel();
_cancelToken?.cancel();
_reachability.removeListener(_onReachabilityChanged);
_stopPolling();
super.dispose();
}
}
+8 -6
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../model/arp_scanner_status.dart';
import '../utils/backend_reachability.dart';
import '../utils/duration_formatter.dart';
import '../utils/oott_api.dart';
import '../utils/polled_value.dart';
@@ -44,9 +45,10 @@ class _ArpScannerCardState extends State<ArpScannerCard> {
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _polled,
listenable: Listenable.merge([_polled, BackendReachability.instance]),
builder: (context, _) {
if (_polled.freshness == PolledFreshness.initialLoading) {
final freshness = effectiveFreshness(_polled);
if (freshness == PolledFreshness.initialLoading) {
return const Card(
child: Padding(
padding: EdgeInsets.all(16),
@@ -55,8 +57,8 @@ class _ArpScannerCardState extends State<ArpScannerCard> {
);
}
final (color, label, sublabel) = _resolveState();
final isStale = _polled.freshness == PolledFreshness.stale;
final (color, label, sublabel) = _resolveState(freshness);
final isStale = freshness == PolledFreshness.stale;
return Card(
clipBehavior: Clip.antiAlias,
@@ -111,8 +113,8 @@ class _ArpScannerCardState extends State<ArpScannerCard> {
);
}
(Color, String, String?) _resolveState() {
if (_polled.freshness == PolledFreshness.error) {
(Color, String, String?) _resolveState(PolledFreshness freshness) {
if (freshness == PolledFreshness.error) {
return (
Colors.red,
'Error',
+10 -5
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../model/device_summary.dart';
import '../utils/backend_reachability.dart';
import '../utils/oott_api.dart';
import '../utils/polled_value.dart';
import 'polled_stale_indicator.dart';
@@ -42,8 +43,12 @@ class _DeviceSummaryCardState extends State<DeviceSummaryCard> {
child: Padding(
padding: const EdgeInsets.all(16),
child: ListenableBuilder(
listenable: _polled,
listenable: Listenable.merge([
_polled,
BackendReachability.instance,
]),
builder: (context, _) {
final freshness = effectiveFreshness(_polled);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -53,14 +58,14 @@ class _DeviceSummaryCardState extends State<DeviceSummaryCard> {
'Devices',
style: Theme.of(context).textTheme.titleLarge,
),
if (_polled.freshness == PolledFreshness.stale) ...[
if (freshness == PolledFreshness.stale) ...[
const SizedBox(width: 6),
PolledStaleIndicator(polled: _polled),
],
],
),
const SizedBox(height: 12),
..._buildBody(context),
..._buildBody(context, freshness),
],
);
},
@@ -70,8 +75,8 @@ class _DeviceSummaryCardState extends State<DeviceSummaryCard> {
);
}
List<Widget> _buildBody(BuildContext context) {
switch (_polled.freshness) {
List<Widget> _buildBody(BuildContext context, PolledFreshness freshness) {
switch (freshness) {
case PolledFreshness.initialLoading:
return const [Center(child: CircularProgressIndicator())];
case PolledFreshness.error:
+8 -6
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../model/mdns_scanner_status.dart';
import '../utils/backend_reachability.dart';
import '../utils/duration_formatter.dart';
import '../utils/oott_api.dart';
import '../utils/polled_value.dart';
@@ -44,9 +45,10 @@ class _MdnsScannerCardState extends State<MdnsScannerCard> {
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _polled,
listenable: Listenable.merge([_polled, BackendReachability.instance]),
builder: (context, _) {
if (_polled.freshness == PolledFreshness.initialLoading) {
final freshness = effectiveFreshness(_polled);
if (freshness == PolledFreshness.initialLoading) {
return const Card(
child: Padding(
padding: EdgeInsets.all(16),
@@ -55,8 +57,8 @@ class _MdnsScannerCardState extends State<MdnsScannerCard> {
);
}
final (color, label, sublabels) = _resolveState();
final isStale = _polled.freshness == PolledFreshness.stale;
final (color, label, sublabels) = _resolveState(freshness);
final isStale = freshness == PolledFreshness.stale;
return Card(
clipBehavior: Clip.antiAlias,
@@ -111,8 +113,8 @@ class _MdnsScannerCardState extends State<MdnsScannerCard> {
);
}
(Color, String, List<String>) _resolveState() {
if (_polled.freshness == PolledFreshness.error) {
(Color, String, List<String>) _resolveState(PolledFreshness freshness) {
if (freshness == PolledFreshness.error) {
return (
Colors.red,
'Error',
+65
View File
@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../utils/backend_reachability.dart';
class OfflineBanner extends StatelessWidget {
const OfflineBanner({super.key});
@override
Widget build(BuildContext context) {
final reachability = BackendReachability.instance;
return ListenableBuilder(
listenable: reachability,
builder: (context, _) {
if (reachability.isOnline) return const SizedBox.shrink();
final theme = Theme.of(context);
final message = !reachability.deviceHasNetwork
? 'No network connection.'
: reachability.isProbing
? 'Reconnecting to backend…'
: reachability.lastErrorMessage ?? 'Cannot reach backend.';
return Material(
color: theme.colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Icon(
Icons.cloud_off,
size: 18,
color: theme.colorScheme.onErrorContainer,
),
const SizedBox(width: 12),
Expanded(
child: Text(
message,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onErrorContainer,
),
),
),
TextButton(
onPressed: reachability.isProbing
? null
: () => reachability.probe(),
style: TextButton.styleFrom(
foregroundColor: theme.colorScheme.onErrorContainer,
),
child: const Text('Retry'),
),
TextButton(
onPressed: () => context.go('/settings'),
style: TextButton.styleFrom(
foregroundColor: theme.colorScheme.onErrorContainer,
),
child: const Text('Settings'),
),
],
),
),
);
},
);
}
}
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import '../utils/backend_reachability.dart';
import '../utils/duration_formatter.dart';
import '../utils/polled_value.dart';
@@ -39,9 +40,15 @@ class _PolledStaleIndicatorState extends State<PolledStaleIndicator> {
DateTime.now().difference(lastSuccessAt).inSeconds.toDouble(),
)
: 'a while';
final message = widget.polled.lastErrorMessage != null
? 'Last updated $ago ago — ${widget.polled.lastErrorMessage}'
: 'Last updated $ago ago';
final isOffline = !BackendReachability.instance.isOnline;
final String message;
if (isOffline) {
message = 'Offline — last updated $ago ago';
} else if (widget.polled.lastErrorMessage != null) {
message = 'Last updated $ago ago — ${widget.polled.lastErrorMessage}';
} else {
message = 'Last updated $ago ago';
}
return Tooltip(
message: message,
child: Icon(
+35 -13
View File
@@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart';
import '../model/arp_scanner_status.dart';
import '../model/mdns_scanner_status.dart';
import '../navigation.dart';
import '../utils/backend_reachability.dart';
import '../utils/duration_formatter.dart';
import '../utils/oott_api.dart';
import '../utils/polled_value.dart';
@@ -101,10 +102,16 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: Listenable.merge([_arp, _mdns]),
listenable: Listenable.merge([
_arp,
_mdns,
BackendReachability.instance,
]),
builder: (context, _) {
if (_arp.freshness == PolledFreshness.initialLoading ||
_mdns.freshness == PolledFreshness.initialLoading) {
final arpFreshness = effectiveFreshness(_arp);
final mdnsFreshness = effectiveFreshness(_mdns);
if (arpFreshness == PolledFreshness.initialLoading ||
mdnsFreshness == PolledFreshness.initialLoading) {
return const Card(
child: Padding(
padding: EdgeInsets.all(16),
@@ -113,8 +120,8 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
);
}
final (arpColor, arpText) = _resolveArp();
final (mdnsColor, mdnsText) = _resolveMdns();
final (arpColor, arpText) = _resolveArp(arpFreshness);
final (mdnsColor, mdnsText) = _resolveMdns(mdnsFreshness);
return Card(
clipBehavior: Clip.antiAlias,
@@ -130,9 +137,23 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
_scannerRow(context, arpColor, 'ARP', arpText, _arp),
_scannerRow(
context,
arpColor,
'ARP',
arpText,
_arp,
arpFreshness,
),
const SizedBox(height: 8),
_scannerRow(context, mdnsColor, 'mDNS', mdnsText, _mdns),
_scannerRow(
context,
mdnsColor,
'mDNS',
mdnsText,
_mdns,
mdnsFreshness,
),
],
),
),
@@ -148,9 +169,10 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
String name,
String statusText,
PolledValue polled,
PolledFreshness freshness,
) {
Widget dot = Icon(Icons.circle, color: color, size: 12);
if (polled.freshness == PolledFreshness.error) {
if (freshness == PolledFreshness.error) {
dot = Tooltip(message: polled.lastErrorMessage ?? 'Error', child: dot);
}
return Row(
@@ -161,7 +183,7 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
child: Row(
children: [
Text(name, style: Theme.of(context).textTheme.bodyMedium),
if (polled.freshness == PolledFreshness.stale) ...[
if (freshness == PolledFreshness.stale) ...[
const SizedBox(width: 6),
PolledStaleIndicator(polled: polled),
],
@@ -179,8 +201,8 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
);
}
(Color, String) _resolveArp() {
if (_arp.freshness == PolledFreshness.error) {
(Color, String) _resolveArp(PolledFreshness freshness) {
if (freshness == PolledFreshness.error) {
return (Colors.red, 'Error');
}
final status = _arp.value!;
@@ -202,8 +224,8 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
return (Colors.grey, 'Not yet started');
}
(Color, String) _resolveMdns() {
if (_mdns.freshness == PolledFreshness.error) {
(Color, String) _resolveMdns(PolledFreshness freshness) {
if (freshness == PolledFreshness.error) {
return (Colors.red, 'Error');
}
final status = _mdns.value!;
@@ -5,11 +5,13 @@
import FlutterMacOS
import Foundation
import connectivity_plus
import encrypter
import shared_preferences_foundation
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
EncrypterPlugin.register(with: registry.registrar(forPlugin: "EncrypterPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
+49 -1
View File
@@ -65,6 +65,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
connectivity_plus:
dependency: "direct main"
description:
name: connectivity_plus
sha256: "62ffa266d9a23b79fb3fcbc206afc00bb979417ba57b1324c546b5aab95ba057"
url: "https://pub.dev"
source: hosted
version: "7.1.1"
connectivity_plus_platform_interface:
dependency: transitive
description:
name: connectivity_plus_platform_interface
sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
convert:
dependency: transitive
description:
@@ -81,6 +97,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.7"
dbus:
dependency: transitive
description:
name: dbus
sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91"
url: "https://pub.dev"
source: hosted
version: "0.7.13"
dio:
dependency: "direct main"
description:
@@ -336,6 +360,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
nm:
dependency: transitive
description:
name: nm
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
url: "https://pub.dev"
source: hosted
version: "0.5.0"
objective_c:
dependency: transitive
description:
@@ -408,6 +440,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
platform:
dependency: transitive
description:
@@ -669,6 +709,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
url: "https://pub.dev"
source: hosted
version: "7.0.1"
yaml:
dependency: transitive
description:
@@ -678,5 +726,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.10.8 <4.0.0"
dart: ">=3.11.0 <4.0.0"
flutter: ">=3.38.4"
+1
View File
@@ -14,6 +14,7 @@ dependencies:
provider: ^6.1.5
dio: ^5.9.1
dio_smart_retry: ^7.0.1
connectivity_plus: ^7.0.0
encrypter: ^2.0.0
shared_preferences: ^2.5.4
fl_chart: ^1.2.0
@@ -6,10 +6,13 @@
#include "generated_plugin_registrant.h"
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
#include <encrypter/encrypter_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
EncrypterPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("EncrypterPluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar(
@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
connectivity_plus
encrypter
url_launcher_windows
)