diff --git a/lib/main.dart b/lib/main.dart index 04ed3daa6d..e96cccd995 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -71,6 +71,7 @@ import 'utilities/desktop_startup_and_reset.dart'; import 'utilities/enums/backup_frequency_type.dart'; import 'utilities/flutter_secure_storage_interface.dart'; import 'utilities/logger.dart'; +import 'utilities/native_library_check.dart'; import 'utilities/prefs.dart'; import 'utilities/stack_file_system.dart'; import 'utilities/util.dart'; @@ -100,7 +101,7 @@ void main(List args) async { StackFileSystem.setDesktopOverrideDir(args.last); } - final loadCoinlibFuture = loadCoinlib(); + final loadCoinlibFuture = loadCoinlibOrRecordFailure(loadCoinlib); GoogleFonts.config.allowRuntimeFetching = false; if (Platform.isIOS) { @@ -303,6 +304,10 @@ void main(List args) async { await loadCoinlibFuture; + // Before any wallet loads, so a quarantined library is reported once on + // the home screen instead of surfacing as wallets that fail to open. + checkNativeLibrariesOnStartup(); + await MainDB.instance.initMainDB(); ThemeService.instance.init(MainDB.instance); diff --git a/lib/pages/home_view/home_view.dart b/lib/pages/home_view/home_view.dart index 12867fc2f0..f1f3e04290 100644 --- a/lib/pages/home_view/home_view.dart +++ b/lib/pages/home_view/home_view.dart @@ -35,6 +35,7 @@ import '../../widgets/animated_widgets/rotate_icon.dart'; import '../../widgets/app_icon.dart'; import '../../widgets/background.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/onetime_popups/native_library_warning_dialog.dart'; import '../../widgets/small_tor_icon.dart'; import '../../widgets/stack_dialog.dart'; import '../buy_view/buy_view.dart'; @@ -243,6 +244,10 @@ class _HomeViewState extends ConsumerState { // showOneTimeTorHasBeenAddedDialogIfRequired(context); // }); + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(showNativeLibraryWarningIfRequired(context)); + }); + _idleMonitor?.attach(); ref.read(prefsChangeNotifierProvider).addListener(_prefsTimeoutListener); @@ -306,9 +311,9 @@ class _HomeViewState extends ConsumerState { key: _key, appBar: AppBar( automaticallyImplyLeading: false, - backgroundColor: Theme.of( - context, - ).extension()!.backgroundAppBar, + backgroundColor: Theme.of(context) + .extension()! + .backgroundAppBar, title: Row( children: [ GestureDetector( @@ -343,11 +348,10 @@ class _HomeViewState extends ConsumerState { key: const Key("walletsViewAlertsButton"), size: 36, shadows: const [], - color: Theme.of( - context, - ).extension()!.backgroundAppBar, - icon: - ref.watch(pAnyGlobalUnreadNotifications) + color: Theme.of(context) + .extension()! + .backgroundAppBar, + icon: ref.watch(pAnyGlobalUnreadNotifications) ? SvgPicture.file( File( ref.watch( @@ -358,60 +362,58 @@ class _HomeViewState extends ConsumerState { ), width: 20, height: 20, - color: - ref.watch(pAnyGlobalUnreadNotifications) + color: ref.watch(pAnyGlobalUnreadNotifications) ? null - : Theme.of( - context, - ).extension()!.topNavIconPrimary, + : Theme.of(context) + .extension()! + .topNavIconPrimary, ) : SvgPicture.asset( Assets.svg.bell, width: 20, height: 20, - color: - ref.watch(pAnyGlobalUnreadNotifications) + color: ref.watch(pAnyGlobalUnreadNotifications) ? null - : Theme.of( - context, - ).extension()!.topNavIconPrimary, + : Theme.of(context) + .extension()! + .topNavIconPrimary, ), onPressed: () { // reset unread state ref.refresh(unreadNotificationsStateProvider); - Navigator.of( - context, - ).pushNamed(NotificationsView.routeName).then((_) { - final Set unreadNotificationIds = ref - .read(unreadNotificationsStateProvider.state) - .state; - if (unreadNotificationIds.isEmpty) return; - - final List> futures = []; - for ( - int i = 0; - i < unreadNotificationIds.length - 1; - i++ - ) { - futures.add( - ref - .read(notificationsProvider) - .markAsRead( - unreadNotificationIds.elementAt(i), - false, - ), - ); - } - - // wait for multiple to update if any - Future.wait(futures).then((_) { - // only notify listeners once - ref - .read(notificationsProvider) - .markAsRead(unreadNotificationIds.last, true); - }); - }); + Navigator.of(context) + .pushNamed(NotificationsView.routeName) + .then((_) { + final Set unreadNotificationIds = ref + .read(unreadNotificationsStateProvider.state) + .state; + if (unreadNotificationIds.isEmpty) return; + + final List> futures = []; + for ( + int i = 0; + i < unreadNotificationIds.length - 1; + i++ + ) { + futures.add( + ref + .read(notificationsProvider) + .markAsRead( + unreadNotificationIds.elementAt(i), + false, + ), + ); + } + + // wait for multiple to update if any + Future.wait(futures).then((_) { + // only notify listeners once + ref + .read(notificationsProvider) + .markAsRead(unreadNotificationIds.last, true); + }); + }); }, ), ), @@ -425,23 +427,22 @@ class _HomeViewState extends ConsumerState { key: const Key("walletsViewSettingsButton"), size: 36, shadows: const [], - color: Theme.of( - context, - ).extension()!.backgroundAppBar, + color: Theme.of(context) + .extension()! + .backgroundAppBar, icon: SvgPicture.asset( Assets.svg.gear, - color: Theme.of( - context, - ).extension()!.topNavIconPrimary, + color: Theme.of(context) + .extension()! + .topNavIconPrimary, width: 20, height: 20, ), onPressed: () { //todo: check if print needed // debugPrint("main view settings tapped"); - Navigator.of( - context, - ).pushNamed(GlobalSettingsView.routeName); + Navigator.of(context) + .pushNamed(GlobalSettingsView.routeName); }, ), ), @@ -455,9 +456,9 @@ class _HomeViewState extends ConsumerState { ref.watch(prefsChangeNotifierProvider).enableExchange) Container( decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.backgroundAppBar, + color: Theme.of(context) + .extension()! + .backgroundAppBar, boxShadow: Theme.of(context) .extension()! diff --git a/lib/pages_desktop_specific/desktop_home_view.dart b/lib/pages_desktop_specific/desktop_home_view.dart index 1d2ed11cba..c6e92e8644 100644 --- a/lib/pages_desktop_specific/desktop_home_view.dart +++ b/lib/pages_desktop_specific/desktop_home_view.dart @@ -27,6 +27,7 @@ import '../utilities/enums/backup_frequency_type.dart'; import '../utilities/idle_monitor.dart'; import '../utilities/prefs.dart'; import '../widgets/background.dart'; +import '../widgets/onetime_popups/native_library_warning_dialog.dart'; import 'address_book_view/desktop_address_book.dart'; import 'desktop_buy/desktop_buy_view.dart'; import 'desktop_exchange/desktop_exchange_view.dart'; @@ -110,6 +111,10 @@ class _DesktopHomeViewState extends ConsumerState { // showOneTimeTorHasBeenAddedDialogIfRequired(context); // }); + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(showNativeLibraryWarningIfRequired(context)); + }); + super.initState(); } diff --git a/lib/pages_desktop_specific/password/forgotten_passphrase_restore_from_swb.dart b/lib/pages_desktop_specific/password/forgotten_passphrase_restore_from_swb.dart index f4f4de39e8..a36be4ad3a 100644 --- a/lib/pages_desktop_specific/password/forgotten_passphrase_restore_from_swb.dart +++ b/lib/pages_desktop_specific/password/forgotten_passphrase_restore_from_swb.dart @@ -37,6 +37,7 @@ import '../../widgets/desktop/desktop_dialog.dart'; import '../../widgets/desktop/desktop_scaffold.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/loading_indicator.dart'; +import '../../widgets/onetime_popups/native_library_warning_dialog.dart'; import '../../widgets/stack_text_field.dart'; import 'create_password_view.dart'; @@ -95,9 +96,9 @@ class _ForgottenPassphraseRestoreFromSWBState child: Text( "Decrypting ${AppConfig.prefix} backup file", style: STextStyles.pageTitleH2(context).copyWith( - color: Theme.of( - context, - ).extension()!.textWhite, + color: Theme.of(context) + .extension()! + .textWhite, ), ), ), @@ -188,6 +189,13 @@ class _ForgottenPassphraseRestoreFromSWBState passwordFocusNode = FocusNode(); + // On a fresh install this screen comes before any home view, and a + // restore with a library missing would fail its cryptonote wallets + // with no explanation. + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(showNativeLibraryWarningIfRequired(context)); + }); + super.initState(); } @@ -214,9 +222,8 @@ class _ForgottenPassphraseRestoreFromSWBState ref.refresh(storageCryptoHandlerProvider); await DB.instance.init(); if (mounted) { - Navigator.of( - context, - ).popUntil(ModalRoute.withName(CreatePasswordView.routeName)); + Navigator.of(context) + .popUntil(ModalRoute.withName(CreatePasswordView.routeName)); Navigator.of(context).pop(); } }, @@ -241,9 +248,9 @@ class _ForgottenPassphraseRestoreFromSWBState "Use your ${AppConfig.prefix} backup file to restore your wallets, address book, and wallet preferences.", textAlign: TextAlign.center, style: STextStyles.desktopTextSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textSubtitle1, + color: Theme.of(context) + .extension()! + .textSubtitle1, ), ), const SizedBox(height: 40), @@ -283,9 +290,9 @@ class _ForgottenPassphraseRestoreFromSWBState const SizedBox(width: 24), SvgPicture.asset( Assets.svg.folder, - color: Theme.of( - context, - ).extension()!.textDark3, + color: Theme.of(context) + .extension()! + .textDark3, width: 24, height: 24, ), @@ -323,9 +330,8 @@ class _ForgottenPassphraseRestoreFromSWBState key: const Key("restoreFromFilePasswordFieldKey"), focusNode: passwordFocusNode, controller: passwordController, - style: STextStyles.desktopTextMedium( - context, - ).copyWith(height: 2), + style: STextStyles.desktopTextMedium(context) + .copyWith(height: 2), obscureText: hidePassword, enableSuggestions: false, autocorrect: false, @@ -356,9 +362,9 @@ class _ForgottenPassphraseRestoreFromSWBState hidePassword ? Assets.svg.eye : Assets.svg.eyeSlash, - color: Theme.of( - context, - ).extension()!.textDark3, + color: Theme.of(context) + .extension()! + .textDark3, width: 24, height: 24, ), diff --git a/lib/utilities/native_library_check.dart b/lib/utilities/native_library_check.dart new file mode 100644 index 0000000000..1bd621976e --- /dev/null +++ b/lib/utilities/native_library_check.dart @@ -0,0 +1,263 @@ +import 'dart:ffi'; +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import '../app_config.dart'; +import '../wallets/crypto_currency/crypto_currency.dart'; +import 'logger.dart'; + +enum NativeLibraryProblem { missing, blockedByAntivirus, loadFailed } + +typedef NativeLibraryCheckResult = ({ + NativeLibraryProblem problem, + String path, + String error, +}); + +/// One library that failed the startup check. +typedef NativeLibraryFailure = ({ + String displayName, + NativeLibraryCheckResult result, +}); + +/// A native library the desktop builds bundle and open through dart:ffi. +class NativeLibrary { + const NativeLibrary({ + required this.displayName, + required this.windows, + required this.linux, + required this.macos, + this.isBundled = _always, + }); + + /// What stops working without it, worded to start a sentence: "Monero + /// wallets". + final String displayName; + + /// The name each loader passes to DynamicLibrary.open on that platform. + final String windows; + final String linux; + final String macos; + + /// Whether this build ships the library. Whitelabel builds leave coins out, + /// and their libraries with them. + final bool Function() isBundled; + + String? fileName(String os) => switch (os) { + "windows" => windows, + "linux" => linux, + "macos" => macos, + _ => null, + }; +} + +bool _always() => true; +bool _hasMonero() => AppConfig.coins.whereType().isNotEmpty; +bool _hasWownero() => AppConfig.coins.whereType().isNotEmpty; +bool _hasSalvium() => AppConfig.coins.whereType().isNotEmpty; + +/// coinlib's secp256k1, named as in its secp256k1_io.dart. Every non +/// cryptonote wallet needs it. +const secp256k1NativeLibrary = NativeLibrary( + displayName: "Most wallets", + windows: "secp256k1.dll", + linux: "libsecp256k1.so", + macos: "secp256k1.framework/secp256k1", +); + +/// Checked at startup in this order. The cryptonote names are the ones +/// cs_monero, cs_wownero and cs_salvium pass to DynamicLibrary.open; their +/// getters are private, so a rename there has to be mirrored here. +const nativeLibraries = [ + NativeLibrary( + displayName: "Monero wallets", + windows: "monero_libwallet2_api_c.dll", + linux: "monero_libwallet2_api_c.so", + macos: "MoneroWallet.framework/MoneroWallet", + isBundled: _hasMonero, + ), + NativeLibrary( + displayName: "Wownero wallets", + windows: "wownero_libwallet2_api_c.dll", + linux: "wownero_libwallet2_api_c.so", + macos: "WowneroWallet.framework/WowneroWallet", + isBundled: _hasWownero, + ), + NativeLibrary( + displayName: "Salvium wallets", + windows: "salvium_libwallet2_api_c.dll", + linux: "salvium_libwallet2_api_c.so", + macos: "SalviumWallet.framework/SalviumWallet", + isBundled: _hasSalvium, + ), + secp256k1NativeLibrary, +]; + +/// Where the app bundles [fileName] on [os], given the running executable. +String nativeLibraryPath(String fileName, String os, String executable) { + final path = os == "windows" ? p.windows : p.posix; + final dir = path.dirname(executable); + return switch (os) { + "linux" => path.join(dir, "lib", fileName), + "macos" => path.normalize(path.join(dir, "..", "Frameworks", fileName)), + _ => path.join(dir, fileName), + }; +} + +/// Loads [fileName] and returns null, or why it failed to load. +NativeLibraryCheckResult? checkNativeLibrary( + String fileName, { + required String os, + required String executable, + void Function(String) open = _open, + bool Function(String) exists = _exists, +}) { + try { + open(fileName); + return null; + } catch (e) { + final error = e is ArgumentError ? "${e.message}" : "$e"; + final path = nativeLibraryPath(fileName, os, executable); + final NativeLibraryProblem problem; + if (!exists(path)) { + problem = NativeLibraryProblem.missing; + } else if (RegExp(r"error code: 22[56]\b").hasMatch(error)) { + // ERROR_VIRUS_INFECTED or ERROR_VIRUS_DELETED + problem = NativeLibraryProblem.blockedByAntivirus; + } else { + problem = NativeLibraryProblem.loadFailed; + } + return (problem: problem, path: path, error: error); + } +} + +void _open(String name) => DynamicLibrary.open(name); +bool _exists(String path) => File(path).existsSync(); + +String nativeLibraryErrorMessage( + String displayName, + NativeLibraryCheckResult result, +) { + const app = AppConfig.appName; + final reason = switch (result.problem) { + NativeLibraryProblem.missing => + "a required file is missing. Antivirus software may have quarantined " + "it. Restore it from quarantine or reinstall $app, then try again.", + NativeLibraryProblem.blockedByAntivirus => + "antivirus software blocked a required file. Allow it in your " + "antivirus settings or restore it from quarantine, then try again.", + NativeLibraryProblem.loadFailed => + "a required library failed to load. Reinstalling $app may fix this.", + }; + return "$displayName can't be used because $reason\n\n" + "File: ${result.path}\nError: ${result.error}"; +} + +bool _isDesktop(String os) => os == "windows" || os == "linux" || os == "macos"; + +final _failures = []; +String? _coinlibError; + +/// Starts coinlib's secp256k1 load for main(). +/// +/// coinlib throws synchronously when the file won't open, which would end +/// main() long before the home screen could say which file is broken. On +/// desktop the error is kept for [checkNativeLibrariesOnStartup] to report +/// and the app carries on; wallets that need coinlib fail their own init. +/// On mobile the library can't go missing on its own, so the error +/// propagates as it always has. Logging isn't initialised yet, so the check +/// is what logs it. +Future loadCoinlibOrRecordFailure(Future Function() load) async { + try { + await load(); + } catch (e) { + if (!_isDesktop(Platform.operatingSystem)) rethrow; + _coinlibError = "$e"; + } +} + +/// Tries to open every bundled library in [nativeLibraries], in order, and +/// remembers the ones that fail for the home screen to report once. +/// +/// Desktop only; elsewhere it does nothing. It runs on the main isolate +/// before any wallet loads, so it competes with nothing a user is waiting +/// on, and a wallet's later open of the same file is a cache hit. Never +/// throws: a broken check is logged and counts as a pass, because the check +/// must not be the thing that keeps the app from starting. +void checkNativeLibrariesOnStartup({ + List libraries = nativeLibraries, + String? os, + String? executable, + void Function(String) open = _open, + bool Function(String) exists = _exists, +}) { + final coinlibError = _coinlibError; + _coinlibError = null; + try { + os ??= Platform.operatingSystem; + if (!_isDesktop(os)) return; + executable ??= Platform.resolvedExecutable; + } catch (e, s) { + Logging.instance.w( + "Native library check did not run", + error: e, + stackTrace: s, + ); + return; + } + + for (final library in libraries) { + final fileName = library.fileName(os); + if (fileName == null) continue; + final isSecp256k1 = identical(library, secp256k1NativeLibrary); + NativeLibraryCheckResult? result; + try { + if (!library.isBundled()) continue; + result = checkNativeLibrary( + fileName, + os: os, + executable: executable, + open: open, + exists: exists, + ); + if (result == null && isSecp256k1 && coinlibError != null) { + // The file opens but coinlib still failed, so report what it said. + result = ( + problem: NativeLibraryProblem.loadFailed, + path: nativeLibraryPath(fileName, os, executable), + error: coinlibError, + ); + } + } catch (e, s) { + // One entry's trouble must not hide the others. coinlib's own error + // is still worth reporting even if the check of its file blew up. + Logging.instance.w( + "Native library check for ${library.displayName} did not run", + error: e, + stackTrace: s, + ); + if (isSecp256k1 && coinlibError != null) { + result = ( + problem: NativeLibraryProblem.loadFailed, + path: fileName, + error: coinlibError, + ); + } + } + if (result != null) { + _failures.add((displayName: library.displayName, result: result)); + Logging.instance.e( + nativeLibraryErrorMessage(library.displayName, result), + ); + } + } +} + +/// The failures the startup check found, cleared so the warning shows once +/// per launch. +List takeNativeLibraryFailures() { + final failures = List.unmodifiable(_failures); + _failures.clear(); + return failures; +} diff --git a/lib/widgets/onetime_popups/native_library_warning_dialog.dart b/lib/widgets/onetime_popups/native_library_warning_dialog.dart new file mode 100644 index 0000000000..6a59be38fb --- /dev/null +++ b/lib/widgets/onetime_popups/native_library_warning_dialog.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +import '../../utilities/native_library_check.dart'; +import '../../utilities/util.dart'; +import '../stack_dialog.dart'; + +String nativeLibraryWarningTitle(List failures) => + failures.length == 1 + ? "A required file could not be loaded" + : "Some required files could not be loaded"; + +String nativeLibraryWarningMessage(List failures) => [ + for (final f in failures) nativeLibraryErrorMessage(f.displayName, f.result), +].join("\n\n"); + +/// Shows what the startup check found, once, the first time a home view +/// appears after launch. Only the message changes hands: wallets open as +/// they always have, so a broken library never locks anyone out of a seed +/// they can still reach through wallet settings. +Future showNativeLibraryWarningIfRequired(BuildContext context) { + // Taken only once there is a live context to show them in, so a home + // view replaced within its first frame leaves them for the next one. + if (!context.mounted) return Future.value(); + final failures = takeNativeLibraryFailures(); + if (failures.isEmpty) return Future.value(); + + return showDialog( + context: context, + builder: (_) => StackOkDialog( + title: nativeLibraryWarningTitle(failures), + message: nativeLibraryWarningMessage(failures), + desktopPopRootNavigator: true, + maxWidth: Util.isDesktop ? 500 : null, + ), + ); +} diff --git a/test/utilities/native_library_check_test.dart b/test/utilities/native_library_check_test.dart new file mode 100644 index 0000000000..7a8d0ad40c --- /dev/null +++ b/test/utilities/native_library_check_test.dart @@ -0,0 +1,239 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:logger/logger.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/native_library_check.dart'; + +const _exe = r"C:\Program Files\Stack Wallet\stack_wallet.exe"; +const _dll = "monero_libwallet2_api_c.dll"; +const _path = r"C:\Program Files\Stack Wallet\monero_libwallet2_api_c.dll"; + +// Messages from a tester's Windows log. +const _blocked = + "Failed to load dynamic library '$_dll': Operation did not complete " + "successfully because the file contains a virus or potentially unwanted " + "software.\n (error code: 225)"; +const _notFound = + "Failed to load dynamic library '$_dll': The specified module could not " + "be found.\n (error code: 126)"; + +final _monero = nativeLibraries[0]; +final _salvium = nativeLibraries[2]; + +NativeLibraryCheckResult? _check(String? error, {required bool exists}) => + checkNativeLibrary( + _dll, + os: "windows", + executable: _exe, + open: (_) { + if (error != null) throw ArgumentError(error); + }, + exists: (path) { + expect(path, _path); + return exists; + }, + ); + +/// Runs the startup check over the whole table on Windows with [failing] +/// files refusing to open, and returns what it recorded. +List _startup( + Set failing, { + List libraries = nativeLibraries, +}) { + checkNativeLibrariesOnStartup( + libraries: libraries, + os: "windows", + executable: _exe, + open: (name) { + if (failing.contains(name)) throw ArgumentError(_notFound); + }, + exists: (path) => !failing.contains(path.split(r"\").last), + ); + return takeNativeLibraryFailures(); +} + +void main() { + late Directory logDir; + + setUpAll(() async { + logDir = await Directory.systemTemp.createTemp("native_lib_check_test"); + await Logging.instance.initialize(logDir.path, level: Level.off); + }); + + tearDownAll(() => logDir.delete(recursive: true)); + + tearDown(takeNativeLibraryFailures); + + test("file names", () { + expect(_monero.fileName("windows"), _dll); + expect(_salvium.fileName("linux"), "salvium_libwallet2_api_c.so"); + expect( + nativeLibraries[1].fileName("macos"), + "WowneroWallet.framework/WowneroWallet", + ); + expect(_monero.fileName("android"), isNull); + expect(secp256k1NativeLibrary.fileName("windows"), "secp256k1.dll"); + expect(secp256k1NativeLibrary.fileName("linux"), "libsecp256k1.so"); + expect( + secp256k1NativeLibrary.fileName("macos"), + "secp256k1.framework/secp256k1", + ); + expect(nativeLibraries.last, same(secp256k1NativeLibrary)); + }); + + test("bundle paths", () { + expect(nativeLibraryPath(_dll, "windows", _exe), _path); + expect( + nativeLibraryPath("a.so", "linux", "/opt/sw/stack_wallet"), + "/opt/sw/lib/a.so", + ); + expect( + nativeLibraryPath("M.framework/M", "macos", "/A/S.app/Contents/MacOS/S"), + "/A/S.app/Contents/Frameworks/M.framework/M", + ); + expect( + nativeLibraryPath("libsecp256k1.so", "linux", "/opt/sw/stack_wallet"), + "/opt/sw/lib/libsecp256k1.so", + ); + }); + + test("loads", () { + expect(_check(null, exists: true), isNull); + }); + + test("quarantined away", () { + expect( + _check(_notFound, exists: false)!.problem, + NativeLibraryProblem.missing, + ); + }); + + test("blocked by antivirus", () { + expect( + _check(_blocked, exists: true)!.problem, + NativeLibraryProblem.blockedByAntivirus, + ); + }); + + test("other failure", () { + final result = _check(_notFound, exists: true)!; + expect(result.problem, NativeLibraryProblem.loadFailed); + final message = nativeLibraryErrorMessage("Monero wallets", result); + expect(message, startsWith("Monero wallets can't be used")); + expect(message, contains(_path)); + expect(message, contains("error code: 126")); + }); + + group("startup check", () { + test("records nothing when everything loads", () { + expect(_startup({}), isEmpty); + }); + + test("records failures in table order", () { + final failures = _startup({"secp256k1.dll", _dll}); + expect(failures.map((f) => f.displayName), [ + "Monero wallets", + "Most wallets", + ]); + expect(failures.first.result.problem, NativeLibraryProblem.missing); + expect(failures.first.result.path, _path); + }); + + test("failures are handed over once", () { + _startup({_dll}); + expect(takeNativeLibraryFailures(), isEmpty); + }); + + test("skips libraries this build does not ship", () { + final failures = _startup( + {_dll}, + libraries: [ + NativeLibrary( + displayName: "Monero wallets", + windows: _dll, + linux: "x.so", + macos: "X.framework/X", + isBundled: () => false, + ), + ], + ); + expect(failures, isEmpty); + }); + + test("does nothing on mobile", () { + checkNativeLibrariesOnStartup( + os: "android", + executable: "/data/app/stack_wallet", + open: (_) => throw ArgumentError(_notFound), + exists: (_) => false, + ); + expect(takeNativeLibraryFailures(), isEmpty); + }); + + test("a check that blows up counts as a pass", () { + checkNativeLibrariesOnStartup( + os: "windows", + executable: _exe, + open: (_) => throw ArgumentError(_notFound), + exists: (_) => throw StateError("no file system"), + ); + expect(takeNativeLibraryFailures(), isEmpty); + }); + + test("one entry blowing up does not hide the others", () async { + await loadCoinlibOrRecordFailure(() async => throw ArgumentError("x")); + checkNativeLibrariesOnStartup( + os: "windows", + executable: _exe, + open: (name) { + if (name != "salvium_libwallet2_api_c.dll") { + throw ArgumentError(_notFound); + } + }, + // The Monero entry's file system check dies; Wownero's file is + // present but won't load; secp256k1's check dies too. + exists: (path) { + if (path.contains("monero") || path.contains("secp")) { + throw StateError("no file system"); + } + return true; + }, + ); + final failures = takeNativeLibraryFailures(); + expect(failures.map((f) => f.displayName), [ + "Wownero wallets", + "Most wallets", + ]); + expect(failures.first.result.problem, NativeLibraryProblem.loadFailed); + expect(failures.last.result.error, contains("x")); + }); + + test("a coinlib error is reported against secp256k1", () async { + await loadCoinlibOrRecordFailure( + () async => throw StateError("secp256k1_context_create not found"), + ); + final failures = _startup({}); + expect(failures.map((f) => f.displayName), ["Most wallets"]); + expect(failures.single.result.problem, NativeLibraryProblem.loadFailed); + expect(failures.single.result.error, contains("secp256k1_context")); + expect( + failures.single.result.path, + r"C:\Program Files\Stack Wallet\secp256k1.dll", + ); + }); + + test("a missing secp256k1 is reported once", () async { + await loadCoinlibOrRecordFailure(() async => throw ArgumentError("")); + final failures = _startup({"secp256k1.dll"}); + expect(failures.map((f) => f.displayName), ["Most wallets"]); + expect(failures.single.result.problem, NativeLibraryProblem.missing); + }); + + test("a coinlib error is forgotten after one check", () async { + await loadCoinlibOrRecordFailure(() async => throw ArgumentError("")); + _startup({}); + expect(_startup({}), isEmpty); + }); + }); +} diff --git a/test/widgets/onetime_popups/native_library_warning_dialog_test.dart b/test/widgets/onetime_popups/native_library_warning_dialog_test.dart new file mode 100644 index 0000000000..ed394ef9e4 --- /dev/null +++ b/test/widgets/onetime_popups/native_library_warning_dialog_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/utilities/native_library_check.dart'; +import 'package:stackwallet/widgets/onetime_popups/native_library_warning_dialog.dart'; + +const _monero = ( + displayName: "Monero wallets", + result: ( + problem: NativeLibraryProblem.missing, + path: r"C:\Program Files\Stack Wallet\monero_libwallet2_api_c.dll", + error: "The specified module could not be found.\n (error code: 126)", + ), +); + +const _secp = ( + displayName: "Most wallets", + result: ( + problem: NativeLibraryProblem.blockedByAntivirus, + path: r"C:\Program Files\Stack Wallet\secp256k1.dll", + error: + "the file contains a virus or potentially unwanted software.\n " + "(error code: 225)", + ), +); + +void main() { + test("one failure", () { + expect( + nativeLibraryWarningTitle([_monero]), + "A required file could not be loaded", + ); + final message = nativeLibraryWarningMessage([_monero]); + expect( + message, + nativeLibraryErrorMessage("Monero wallets", _monero.result), + ); + expect( + message, + startsWith( + "Monero wallets can't be used because a required file is missing.", + ), + ); + expect(message, endsWith("(error code: 126)")); + }); + + test("several failures", () { + expect( + nativeLibraryWarningTitle([_monero, _secp]), + "Some required files could not be loaded", + ); + final message = nativeLibraryWarningMessage([_monero, _secp]); + expect( + message, + "${nativeLibraryErrorMessage("Monero wallets", _monero.result)}\n\n" + "${nativeLibraryErrorMessage("Most wallets", _secp.result)}", + ); + expect( + message, + contains("Most wallets can't be used because antivirus software blocked"), + ); + expect( + message.indexOf("Monero"), + lessThan(message.indexOf("Most wallets")), + ); + }); +}