Skip to content

feat(window): add cross-platform close confirm and shutdown KDF on exit #2853

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

Merged
Show file tree
Hide file tree
Changes from 10 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
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# First-party
sdk/
# Ignore the SDK symmlink (if it exists)
/sdk/

# Miscellaneous
*.class
Expand Down
9 changes: 6 additions & 3 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import 'package:web_dex/mm2/mm2_api/mm2_api_trezor.dart';
import 'package:web_dex/model/stored_settings.dart';
import 'package:web_dex/performance_analytics/performance_analytics.dart';
import 'package:web_dex/analytics/widgets/analytics_lifecycle_handler.dart';
import 'package:web_dex/sdk/widgets/window_close_handler.dart';
import 'package:web_dex/services/feedback/custom_feedback_form.dart';
import 'package:web_dex/services/logger/get_logger.dart';
import 'package:web_dex/services/storage/get_storage.dart';
Expand Down Expand Up @@ -165,9 +166,11 @@ class MyApp extends StatelessWidget {
darkTheme: _feedbackThemeData(theme),
theme: _feedbackThemeData(theme),
child: AnalyticsLifecycleHandler(
child: app_bloc_root.AppBlocRoot(
storedPrefs: _storedSettings!,
komodoDefiSdk: komodoDefiSdk,
child: WindowCloseHandler(
child: app_bloc_root.AppBlocRoot(
storedPrefs: _storedSettings!,
komodoDefiSdk: komodoDefiSdk,
),
),
),
),
Expand Down
10 changes: 10 additions & 0 deletions lib/mm2/mm2.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ final class MM2 {

Future<bool> isSignedIn() => _kdfSdk.auth.isSignedIn();

/// Dispose the SDK and clean up resources
Future<void> dispose() async {
try {
await _kdfSdk.dispose();
log('KomodoDefiSdk disposed successfully');
} catch (e) {
log('Error disposing KomodoDefiSdk: $e', isError: true);
}
}

Future<KomodoDefiSdk> initialize() async {
if (_initCompleter.isCompleted) return _kdfSdk;
if (_isInitializing) return _initCompleter.future;
Expand Down
166 changes: 166 additions & 0 deletions lib/sdk/widgets/window_close_handler.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_window_close/flutter_window_close.dart';
import 'package:web_dex/app_config/app_config.dart';
import 'package:web_dex/mm2/mm2.dart';
import 'package:web_dex/shared/utils/platform_tuner.dart';
import 'package:web_dex/shared/utils/utils.dart';
import 'package:web_dex/shared/utils/window/window.dart';

/// A widget that handles window close events and SDK disposal across all platforms.
///
/// This widget uses different strategies based on the platform:
/// - Desktop (Windows, macOS, Linux): Uses flutter_window_close for native window close handling
/// - Web: Uses showMessageBeforeUnload for browser beforeunload event
/// - Mobile (iOS, Android): Uses WidgetsBindingObserver for lifecycle management
/// and PopScope for exit confirmation
///
/// In all cases, it ensures the KomodoDefiSdk is properly disposed when the app is closed.
class WindowCloseHandler extends StatefulWidget {
/// Creates a WindowCloseHandler.
///
/// The [child] parameter must not be null.
const WindowCloseHandler({
super.key,
required this.child,
});

/// The widget below this widget in the tree.
final Widget child;

@override
State<WindowCloseHandler> createState() => _WindowCloseHandlerState();
}

class _WindowCloseHandlerState extends State<WindowCloseHandler>
with WidgetsBindingObserver {
/// Tracks if the SDK has been disposed to prevent multiple disposal attempts
bool _hasSdkBeenDisposed = false;

@override
void initState() {
super.initState();
_setupCloseHandler();
}

/// Sets up the appropriate close handler based on the platform.
void _setupCloseHandler() {
if (PlatformTuner.isNativeDesktop) {
// Desktop platforms: Use flutter_window_close
FlutterWindowClose.setWindowShouldCloseHandler(() async {
return await _handleWindowClose();
});
} else if (kIsWeb) {
// Web platform: Use beforeunload event
showMessageBeforeUnload(
'This will close Komodo Wallet and stop all trading activities.');
} else {
// Mobile platforms: Use lifecycle observer
WidgetsBinding.instance.addObserver(this);
}
}

@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);

// Dispose SDK when app is terminated or detached from UI (mobile platforms)
if (state == AppLifecycleState.detached) {
_disposeSDKIfNeeded();
}
}

/// Handles the window close event.
/// Returns true if the window should close, false otherwise.
Future<bool> _handleWindowClose() async {
final context =
scaffoldKey.currentContext ?? (mounted ? this.context : null);

// Show confirmation dialog
final shouldClose = (context == null)
? true
: await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Do you really want to quit?'),
content: const Text(
'This will close Komodo Wallet and stop all trading activities.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Yes'),
),
],
);
},
);

log('Window close handler: User confirmed close - $shouldClose');

// If user confirmed, dispose the SDK
if (shouldClose == true) {
await _disposeSDKIfNeeded();
return true;
}

return false;
}

Future<void> _handlePop() async {
final shouldClose = await _handleWindowClose();
if (shouldClose) {
await SystemNavigator.pop();
}
}

/// Disposes the SDK if it hasn't been disposed already.
Future<void> _disposeSDKIfNeeded() async {
if (!_hasSdkBeenDisposed) {
_hasSdkBeenDisposed = true;

try {
await mm2.dispose();
log('Window close handler: SDK disposed successfully');
} catch (e, s) {
log('Window close handler: error during SDK disposal - $e');
log('Stack trace: ${s.toString()}');
}
}
}

@override
void dispose() {
// Clean up based on platform
if (PlatformTuner.isNativeDesktop) {
FlutterWindowClose.setWindowShouldCloseHandler(null);
} else if (!kIsWeb) {
// Mobile platforms: Remove lifecycle observer
WidgetsBinding.instance.removeObserver(this);
}

super.dispose();
}

@override
Widget build(BuildContext context) {
if (PlatformTuner.isNativeMobile) {
return PopScope(
canPop: false,
onPopInvoked: (didPop) {
if (!didPop) {
_handlePop();
}
},
child: widget.child,
);
}

return widget.child;
}
}
7 changes: 7 additions & 0 deletions lib/shared/utils/platform_tuner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ abstract class PlatformTuner {
defaultTargetPlatform == TargetPlatform.linux;
}

static bool get isNativeMobile {
if (kIsWeb) return false;

return defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS;
}

static Future<void> setWindowTitleAndSize() async {
if (!isNativeDesktop) return;

Expand Down
8 changes: 6 additions & 2 deletions lib/shared/utils/utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ Future<void> copyToClipBoard(

if (!context.mounted) return;
final scaffoldMessenger = ScaffoldMessenger.maybeOf(context) ??
ScaffoldMessenger.of(scaffoldKey.currentContext!); scaffoldMessenger.showSnackBar(
ScaffoldMessenger.of(scaffoldKey.currentContext!);
scaffoldMessenger.showSnackBar(
SnackBar(
width: isMobile ? null : 400.0,
content: Row(
Expand All @@ -61,7 +62,7 @@ Future<void> copyToClipBoard(
);
} catch (e) {
log('Error copyToClipBoard: $e', isError: true);
if (!context.mounted) return; // Show error feedback using SnackBar
if (!context.mounted) return; // Show error feedback using SnackBar
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Failed to copy to clipboard'),
Expand Down Expand Up @@ -286,6 +287,9 @@ Future<void> log(
try {
await logger.write(message, path);

// TODO: Add a `.dispose()` method to the logger library and call it before
// the app is disposed.

performance.logTimeWritingLogs(timer.elapsedMilliseconds);
} catch (e) {
// TODO: replace below with crashlytics reporting or show UI the printed
Expand Down
61 changes: 58 additions & 3 deletions lib/shared/utils/window/window_native.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,64 @@
import 'dart:io';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:web_dex/app_config/app_config.dart';

String getOriginUrl() {
return 'https://app.komodoplatform.com';
}

/// Shows a confirmation dialog when the user attempts to close the application.
///
/// On native platforms there is no direct equivalent to the browser
/// `onbeforeunload` event, however we can intercept the back button or window
/// close requests using a [WidgetsBindingObserver].
class _BeforeUnloadObserver with WidgetsBindingObserver {
_BeforeUnloadObserver(this.message);

final String message;
bool _dialogShown = false;

@override
Future<bool> didPopRoute() async {
if (_dialogShown) return true;

final context = scaffoldKey.currentContext;
if (context == null) return true;

_dialogShown = true;

final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('OK'),
),
],
),
);

_dialogShown = false;

if (result == true) {
// SystemNavigator.pop works on mobile; on desktop fall back to exit(0).
await SystemNavigator.pop();
if (!Platform.isAndroid && !Platform.isIOS) exit(0);
}
return true;
}
}

_BeforeUnloadObserver? _observer;

void showMessageBeforeUnload(String message) {
// TODO: implement
// don't throw an exception here, since native platforms should continue
// to work even if we can't prevent closure
_observer ??= _BeforeUnloadObserver(message);
WidgetsBinding.instance.addObserver(_observer!);
}
10 changes: 7 additions & 3 deletions lib/views/main_layout/main_layout.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:web_dex/app_config/app_config.dart';
import 'package:web_dex/generated/codegen_loader.g.dart';
Expand Down Expand Up @@ -28,14 +29,17 @@ class _MainLayoutState extends State<MainLayout> {
@override
void initState() {
// TODO: localize
showMessageBeforeUnload('Are you sure you want to leave?');
if (kIsWeb) {
showMessageBeforeUnload('Are you sure you want to leave?');
}

WidgetsBinding.instance.addPostFrameCallback((_) async {
final tradingEnabled =
context.read<TradingStatusBloc>().state is TradingEnabled;

await AlphaVersionWarningService().run();
await updateBloc.init();

final tradingEnabled =
context.read<TradingStatusBloc>().state is TradingEnabled;
if (tradingEnabled &&
kShowTradingWarning &&
!await _hasAgreedNoTrading()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ class WalletManageSection extends StatelessWidget {
child: WalletManagerSearchField(onChange: onSearchChange),
),
),
if (isAuthenticated ) ...[
if (isAuthenticated) ...[
Spacer(),
CoinsWithBalanceCheckbox(
withBalance: withBalance,
Expand Down
4 changes: 4 additions & 0 deletions linux/flutter/generated_plugin_registrant.cc
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@
#include "generated_plugin_registrant.h"

#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <flutter_window_close/flutter_window_close_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
#include <window_size/window_size_plugin.h>

void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
g_autoptr(FlPluginRegistrar) flutter_window_close_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterWindowClosePlugin");
flutter_window_close_plugin_register_with_registrar(flutter_window_close_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
Expand Down
1 change: 1 addition & 0 deletions linux/flutter/generated_plugins.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_linux
flutter_window_close
url_launcher_linux
window_size
)
Expand Down
Loading
Loading