From 8c3f2a303a417d5cbcd8eafe708115cac7d57a85 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 3 Nov 2026 14:06:30 -0600 Subject: [PATCH 1/4] Check that the desktop native libraries load at startup Windows Defender quarantined monero_libwallet2_api_c.dll on a tester's machine. The app opened the Monero wallet anyway, swallowed the load error inside open(), and the wallet hung with no hint of what had gone wrong. This adds a short table of the native libraries the desktop builds bundle (Monero, Wownero, Salvium and coinlib's secp256k1) and tries to open each one at startup, before any wallet loads. Failures are classified as missing, blocked by antivirus or failed to load, logged once, and kept for the home screen to show. The check runs sequentially on the main isolate while nothing else is loading, so it adds nothing to wallet load time, and it never throws. Nothing on the open paths changes. Gating init() or open() on the check would add overhead to every wallet and would lock a user out of a seed they can still reach through wallet settings, so a broken library is reported and the app otherwise behaves as before. This is a bandaid until native assets or signed Windows DLLs. coinlib's loader throws synchronously when secp256k1 won't open, which would end main() before the home screen could warn. On desktop that error is now kept for the check to report and startup continues; wallets that need coinlib fail their own init as they do today. Mobile is unchanged. --- lib/main.dart | 7 +- lib/utilities/native_library_check.dart | 263 ++++++++++++++++++ test/utilities/native_library_check_test.dart | 239 ++++++++++++++++ 3 files changed, 508 insertions(+), 1 deletion(-) create mode 100644 lib/utilities/native_library_check.dart create mode 100644 test/utilities/native_library_check_test.dart 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/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/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); + }); + }); +} From 924b75269b2e414374cc709dae76a118deedccef Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 3 Nov 2026 14:08:33 -0600 Subject: [PATCH 2/4] Warn once on the home screen when a native library failed to load The first time a home view appears after launch, show one dialog listing what the startup check could not load: which wallets it affects, the file, where it should be, and the loader's error. One failure gets the title "A required file could not be loaded", several get "Some required files could not be loaded" with each failure separated by a blank line. The failures are handed over once, so coming back to the home screen within the same run shows nothing. There is no retry button and nothing else changes: after dismissing it the user can open, create and restore wallets exactly as before, including a wallet whose library is broken, so the seed in wallet settings stays reachable. --- lib/pages/home_view/home_view.dart | 5 ++ .../desktop_home_view.dart | 5 ++ .../native_library_warning_dialog.dart | 36 ++++++++++ .../native_library_warning_dialog_test.dart | 65 +++++++++++++++++++ 4 files changed, 111 insertions(+) create mode 100644 lib/widgets/onetime_popups/native_library_warning_dialog.dart create mode 100644 test/widgets/onetime_popups/native_library_warning_dialog_test.dart diff --git a/lib/pages/home_view/home_view.dart b/lib/pages/home_view/home_view.dart index 12867fc2f0..00fcec7dae 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); diff --git a/lib/pages_desktop_specific/desktop_home_view.dart b/lib/pages_desktop_specific/desktop_home_view.dart index f61cc94111..4a51d46161 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'; @@ -108,6 +109,10 @@ class _DesktopHomeViewState extends ConsumerState { // showOneTimeTorHasBeenAddedDialogIfRequired(context); // }); + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(showNativeLibraryWarningIfRequired(context)); + }); + super.initState(); } 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/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")), + ); + }); +} From 3ecc51e212f50433c46b307d125ee98eb7d3f828 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 3 Nov 2026 15:22:29 -0600 Subject: [PATCH 3/4] Warn on the fresh install restore screen as well On desktop a new install can go straight from the intro to "Restore from Stack Wallet backup", which runs before any home view exists. With a library missing, that restore would fail its Monero, Wownero or Salvium wallets with nothing to say why, and the warning would only appear on the home screen afterwards. The restore screen now shows the same one-time dialog when it appears. The failures are handed over once, so on an existing install, where the home screen has already shown them, this screen shows nothing. --- .../password/forgotten_passphrase_restore_from_swb.dart | 8 ++++++++ 1 file changed, 8 insertions(+) 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..ecdef557c3 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'; @@ -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(); } From d7e2a50ffbabf6f6f4d2ed8f5e9d0b527643d923 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 3 Nov 2026 15:36:55 -0600 Subject: [PATCH 4/4] style: dart format CI formats every Dart file a PR touches, and these three were not in dart format's current style before this branch touched them. No code change. --- lib/pages/home_view/home_view.dart | 120 +++++++++--------- .../desktop_home_view.dart | 5 +- ...forgotten_passphrase_restore_from_swb.dart | 34 +++-- 3 files changed, 76 insertions(+), 83 deletions(-) diff --git a/lib/pages/home_view/home_view.dart b/lib/pages/home_view/home_view.dart index 00fcec7dae..f1f3e04290 100644 --- a/lib/pages/home_view/home_view.dart +++ b/lib/pages/home_view/home_view.dart @@ -311,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( @@ -348,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( @@ -363,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); + }); + }); }, ), ), @@ -430,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); }, ), ), @@ -460,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 4a51d46161..4cec464ffa 100644 --- a/lib/pages_desktop_specific/desktop_home_view.dart +++ b/lib/pages_desktop_specific/desktop_home_view.dart @@ -176,9 +176,8 @@ class _DesktopHomeViewState extends ConsumerState { if (ref.read(prevDesktopMenuItemProvider.state).state == DesktopMenuItemId.myStack && ref.read(prevDesktopMenuItemProvider.state).state == newKey) { - Navigator.of( - myStackViewNavKey.currentContext!, - ).popUntil(ModalRoute.withName(MyStackView.routeName)); + Navigator.of(myStackViewNavKey.currentContext!) + .popUntil(ModalRoute.withName(MyStackView.routeName)); if (ref.read(currentWalletIdProvider.state).state != null) { final wallet = ref .read(pWallets) 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 ecdef557c3..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 @@ -96,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, ), ), ), @@ -222,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(); } }, @@ -249,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), @@ -291,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, ), @@ -331,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, @@ -364,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, ),