CW-974: Add support for automatic node switching. (#2361)

* feat: Add support for automatic node switching.

This change:
- Adds isTrustedNodeForSwitching property to Node class.
- Adds UI Checkbox to mark a node as trusted for automatic switching in Node create or edit form.
- Implements checkNodeHealth method in wallet classes to verify node connectivity.
- Adds NodeSwitchingService for managing automatic node switching based on health checks.
- Adds setting option for user to enable/disable automated node switching

* fix(automatic-node-switch): Set node health check interval to 5 minutes in NodeSwitchingService

* refactor: Update handler method

* feat: Automatic Node Switching Improvements

This change:
- Moves enable automatic node switch to the Manage Nodes Page
- Adds check to verify node health before attempting to sync in EVM, Sol and Tron Wallets.
- Modifies trusted ndoes list for monero
- Adds trigger for Node Health Check when app resumes from background

* fix: Increase default request timeout for Tron and update health check interval for node switch

* feat: Enhance Node health check functionality.

This change adds additional checks for token balance in EVM, Solana and Tron Wallets in checkNodeHealth.

* fix: Update health check interval to 30 seconds in NodeSwitchingService

* Update bitcoin_electrum_server_list.yml

* fix: Electru wallet not connecting to new node after switching

* fix: Automated Node Switch not working on Decred Wallet

* fix: Add more nodes for AutoSwitching

This change:
- Enables more nodes across wallet types for auto switching
- Modifies CTA for checkbox
- Renames flags to fit

* fix: Add migration function for setting up default nodes for autoswitching

* fix: Review and testing fixes

* chore: Adjust balance and history fetch timer
This commit is contained in:
David Adegoke
2025-07-25 16:42:59 +03:00
committed by GitHub
parent 8f4eed4d9b
commit 4b2af63fa5
73 changed files with 639 additions and 68 deletions
+126
View File
@@ -0,0 +1,126 @@
import 'dart:async';
import 'package:cw_core/node.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:cake_wallet/store/app_store.dart';
import 'package:cake_wallet/store/settings_store.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:hive/hive.dart';
class NodeSwitchingService {
NodeSwitchingService({
required this.appStore,
required this.settingsStore,
required this.nodeSource,
});
static const int _healthCheckIntervalSeconds = 30;
Timer? _healthCheckTimer;
bool _isSwitching = false;
bool get isSwitching => _isSwitching;
final AppStore appStore;
final SettingsStore settingsStore;
final Box<Node> nodeSource;
// Track used nodes to cycle through all trusted nodes
final Map<WalletType, List<dynamic>> _usedNodeKeys = {};
void startHealthCheckTimer() {
_healthCheckTimer?.cancel();
_healthCheckTimer = Timer.periodic(
Duration(seconds: _healthCheckIntervalSeconds),
(_) => performHealthCheck(),
);
performHealthCheck();
}
void stopHealthCheckTimer() {
_healthCheckTimer?.cancel();
_healthCheckTimer = null;
}
Future<void> performHealthCheck() async {
if (appStore.wallet == null) return;
if (!settingsStore.enableAutomaticNodeSwitching) return;
if (_isSwitching) return;
try {
final isHealthy = await appStore.wallet!.checkNodeHealth();
if (!isHealthy) {
await _switchToNextTrustedNode();
}
} catch (e) {
printV('Error during health check: $e');
}
}
/// Switch to the next available trusted node
Future<void> _switchToNextTrustedNode() async {
_isSwitching = true;
try {
final walletType = appStore.wallet!.type;
final currentNode = settingsStore.getCurrentNode(walletType);
// Get all trusted nodes for this wallet type
final trustedNodes = nodeSource.values
.where((node) => node.type == walletType && node.isEnabledForAutoSwitching)
.toList();
if (trustedNodes.isEmpty) {
printV('No trusted nodes available for switching');
return;
}
// Initialize used nodes list for this wallet type if it does not exist
_usedNodeKeys.putIfAbsent(walletType, () => []);
// Add current node to used list if not already there
if (!_usedNodeKeys[walletType]!.contains(currentNode.key)) {
_usedNodeKeys[walletType]!.add(currentNode.key);
}
// Get next unused trusted node from the list
Node? nextNode;
for (final node in trustedNodes) {
if (!_usedNodeKeys[walletType]!.contains(node.key)) {
nextNode = node;
break;
}
}
// If all trusted nodes have been used, reset the list and start over
if (nextNode == null) {
printV('All trusted nodes have been tried, resetting and starting over');
_usedNodeKeys[walletType]!.clear();
nextNode = trustedNodes.first;
}
// Add the next node to used list
_usedNodeKeys[walletType]!.add(nextNode.key);
printV('Switching from ${currentNode.uriRaw} to ${nextNode.uriRaw}');
printV('Used nodes for ${walletType}: ${_usedNodeKeys[walletType]}');
// Update the current node in settings
settingsStore.nodes[walletType] = nextNode;
// Connect the wallet to the new node
await appStore.wallet!.connectToNode(node: nextNode);
await appStore.wallet!.startSync();
printV('Successfully switched to node: ${nextNode.uriRaw}');
} catch (e) {
printV('Error switching to next trusted node: $e');
} finally {
_isSwitching = false;
}
}
}
+7
View File
@@ -23,6 +23,7 @@ import 'package:cake_wallet/core/totp_request_details.dart';
import 'package:cake_wallet/core/wallet_creation_service.dart';
import 'package:cake_wallet/core/wallet_loading_service.dart';
import 'package:cake_wallet/core/yat_service.dart';
import 'package:cake_wallet/core/node_switching_service.dart';
import 'package:cake_wallet/entities/biometric_auth.dart';
import 'package:cake_wallet/entities/contact.dart';
import 'package:cake_wallet/entities/contact_record.dart';
@@ -1516,5 +1517,11 @@ Future<void> setup({
getIt.registerFactory(() => DEuroSavingsPage(getIt<DEuroViewModel>()));
getIt.registerLazySingleton(() => NodeSwitchingService(
appStore: getIt.get<AppStore>(),
settingsStore: getIt.get<SettingsStore>(),
nodeSource: _nodeSource,
));
_isSetupFinished = true;
}
+50 -3
View File
@@ -510,7 +510,10 @@ Future<void> defaultSettingsMigration(
providerName: "SwapTrade",
enabled: true,
);
break;
break;
case 50:
migrateExistingNodesToUseAutoSwitching(nodes: nodes, powNodes: powNodes);
break;
default:
break;
}
@@ -1088,7 +1091,7 @@ Future<void> checkCurrentNodes(
if (currentBitcoinElectrumServer == null) {
final cakeWalletElectrum =
Node(uri: cakeWalletBitcoinElectrumUri, type: WalletType.bitcoin, useSSL: false);
Node(uri: cakeWalletBitcoinElectrumUri, type: WalletType.bitcoin, useSSL: false, isEnabledForAutoSwitching: true);
await nodeSource.add(cakeWalletElectrum);
final cakeWalletElectrumTestnet =
Node(uri: publicBitcoinTestnetElectrumUri, type: WalletType.bitcoin, useSSL: false);
@@ -1188,7 +1191,7 @@ Future<void> resetBitcoinElectrumServer(
if (cakeWalletNode == null) {
cakeWalletNode =
Node(uri: cakeWalletBitcoinElectrumUri, type: WalletType.bitcoin, useSSL: false);
Node(uri: cakeWalletBitcoinElectrumUri, type: WalletType.bitcoin, useSSL: false, isEnabledForAutoSwitching: true);
// final cakeWalletElectrumTestnet =
// Node(uri: publicBitcoinTestnetElectrumUri, type: WalletType.bitcoin, useSSL: false);
// await nodeSource.add(cakeWalletElectrumTestnet);
@@ -1272,3 +1275,47 @@ Future<void> removeMoneroWorld(
);
}
}
Future<void> migrateExistingNodesToUseAutoSwitching(
{required Box<Node> nodes, required Box<Node> powNodes}) async {
final listOfDefaultNodesWithAutoSwitching = [
'bitcoincash.stackwallet.com:50002',
'bch.aftrek.org:50002',
'btc-electrum.cakewallet.com:50002',
'fulcrum.sethforprivacy.com:50002',
'default-spv-nodes',
'dcrd.sethforprivacy.com:9108',
'ethereum-rpc.publicnode.com',
'eth.nownodes.io',
'ltc-electrum.cakewallet.com:50002',
'litecoin.stackwallet.com:20063',
'nano.nownodes.io',
'rpc.nano.to',
'node.nautilus.io',
'rpc.nano.to',
'workers.perish.co',
'worker.nanoriver.cc',
'xmr-node.cakewallet.com:18081',
'node.sethforprivacy.com:443',
'nodes.hashvault.pro:18081',
'polygon-bor-rpc.publicnode.com',
'matic.nownodes.io',
'api.mainnet-beta.solana.com:443',
'solana-rpc.publicnode.com:443',
'solana-mainnet.core.chainstack.com',
'api.trongrid.io',
'trx.nownodes.io',
'node3.monerodevs.org:34568',
'node2.monerodevs.org:34568',
'37.27.100.59:10500',
'zano.cakewallet.com:11211',
'electrum.cakewallet.com:50002',
];
for (var node in [...nodes.values.toList(), ...powNodes.values.toList()]) {
if (listOfDefaultNodesWithAutoSwitching.contains(node.uriRaw)) {
node.isEnabledForAutoSwitching = true;
await node.save();
}
}
}
+1
View File
@@ -112,4 +112,5 @@ class PreferencesKey {
'${PreferencesKey.walletConnectPairingTopicsList}_${publicKey}';
static String backgroundSyncLastTrigger(String walletId) => 'background_sync_last_trigger_${walletId}';
static const backgroundSyncNotificationsEnabled = 'background_sync_notifications_enabled';
static const enableAutomaticNodeSwitching = 'enable_automatic_node_switching';
}
+4 -1
View File
@@ -6,6 +6,7 @@ import 'package:cake_wallet/app_scroll_behavior.dart';
import 'package:cake_wallet/buy/order.dart';
import 'package:cake_wallet/core/auth_service.dart';
import 'package:cake_wallet/core/background_sync.dart';
import 'package:cake_wallet/core/node_switching_service.dart';
import 'package:cake_wallet/di.dart';
import 'package:cake_wallet/entities/contact.dart';
import 'package:cake_wallet/entities/default_settings_migration.dart';
@@ -249,7 +250,7 @@ Future<void> initializeAppConfigs({bool loadWallet = true}) async {
payjoinSessionSource: payjoinSessionSource,
anonpayInvoiceInfo: anonpayInvoiceInfo,
havenSeedStore: havenSeedStore,
initialMigrationVersion: 49,
initialMigrationVersion: 50,
);
}
@@ -327,6 +328,7 @@ class AppState extends State<App> with SingleTickerProviderStateMixin {
final authService = getIt.get<AuthService>();
final linkViewModel = getIt.get<LinkViewModel>();
final tradeMonitor = getIt.get<TradeMonitor>();
final nodeSwitchingService = getIt.get<NodeSwitchingService>();
final settingsStore = appStore.settingsStore;
final statusBarColor = Colors.transparent;
final authenticationStore = getIt.get<AuthenticationStore>();
@@ -351,6 +353,7 @@ class AppState extends State<App> with SingleTickerProviderStateMixin {
authService: authService,
linkViewModel: linkViewModel,
tradeMonitor: tradeMonitor,
nodeSwitchingService: nodeSwitchingService,
child: ThemeProvider(
themeStore: appStore.themeStore,
materialAppBuilder: (context, theme, darkTheme, themeMode) => MaterialApp(
+3
View File
@@ -14,6 +14,7 @@ import 'package:cake_wallet/store/app_store.dart';
import 'package:cake_wallet/store/settings_store.dart';
import 'package:cake_wallet/store/authentication_store.dart';
import 'package:cake_wallet/store/dashboard/fiat_conversion_store.dart';
import 'package:cake_wallet/core/node_switching_service.dart';
Future<void> bootstrapOffline() async {
final authenticationStore = getIt.get<AuthenticationStore>();
@@ -40,4 +41,6 @@ void bootstrapOnline(GlobalKey<NavigatorState> navigatorKey, {required bool load
startCurrentFiatApiModeChangeReaction(appStore, settingsStore, fiatConversionStore);
startOnCurrentNodeChangeReaction(appStore);
startFiatRateUpdate(appStore, settingsStore, fiatConversionStore);
getIt.get<NodeSwitchingService>().startHealthCheckTimer();
}
@@ -32,6 +32,7 @@ class NodeForm extends StatelessWidget {
..setLogin((editingNode!.login ?? ''))
..setSSL((editingNode!.isSSL))
..setTrusted((editingNode!.trusted))
..setIsEnabledForAutoSwitching((editingNode!.isEnabledForAutoSwitching))
..setSocksProxy((editingNode!.useSocksProxy))
..setSocksProxyAddress((editingNode!.socksProxyAddress ?? ''));
}
@@ -153,6 +154,25 @@ class NodeForm extends StatelessWidget {
),
),
SizedBox(height: 10.0),
Padding(
padding: EdgeInsets.only(top: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.max,
children: [
Observer(
builder: (_) => StandardCheckbox(
value: nodeViewModel.isEnabledForAutoSwitching,
borderColor: Theme.of(context).colorScheme.surfaceContainerHighest,
iconColor: Theme.of(context).colorScheme.primary,
onChanged: (value) => nodeViewModel.isEnabledForAutoSwitching = value,
caption: S.current.enable_for_auto_switching,
),
),
],
),
),
SizedBox(height: 10.0),
if (nodeViewModel.hasAuthCredentials) ...[
Row(
children: <Widget>[
+8
View File
@@ -2,8 +2,10 @@ import 'dart:async';
import 'dart:io';
import 'package:cake_wallet/bitcoin/bitcoin.dart';
import 'package:cake_wallet/core/auth_service.dart';
import 'package:cake_wallet/core/node_switching_service.dart';
import 'package:cake_wallet/core/totp_request_details.dart';
import 'package:cake_wallet/core/trade_monitor.dart';
import 'package:cake_wallet/di.dart';
import 'package:cake_wallet/utils/device_info.dart';
import 'package:cake_wallet/view_model/link_view_model.dart';
import 'package:cw_core/utils/print_verbose.dart';
@@ -29,6 +31,7 @@ class Root extends StatefulWidget {
required this.authService,
required this.linkViewModel,
required this.tradeMonitor,
required this.nodeSwitchingService,
}) : super(key: key);
final AuthenticationStore authenticationStore;
@@ -38,6 +41,7 @@ class Root extends StatefulWidget {
final Widget child;
final LinkViewModel linkViewModel;
final TradeMonitor tradeMonitor;
final NodeSwitchingService nodeSwitchingService;
@override
RootState createState() => RootState();
@@ -161,6 +165,10 @@ class RootState extends State<Root> with WidgetsBindingObserver {
}
widget.tradeMonitor.resumeTradeMonitoring();
// Trigger node health check when app resumes
widget.nodeSwitchingService.performHealthCheck();
break;
default:
break;
@@ -2,6 +2,7 @@ import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/routes.dart';
import 'package:cake_wallet/src/screens/base_page.dart';
import 'package:cake_wallet/src/screens/nodes/widgets/node_list_row.dart';
import 'package:cake_wallet/src/screens/settings/widgets/settings_switcher_cell.dart';
import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart';
import 'package:cake_wallet/src/widgets/standard_list.dart';
import 'package:cake_wallet/utils/show_pop_up.dart';
@@ -34,7 +35,23 @@ class ManageNodesPage extends BasePage {
onTap: (_) async => await Navigator.of(context).pushNamed(Routes.newNode),
),
),
SizedBox(height: 20),
Observer(
builder: (_) => SettingsSwitcherCell(
key: ValueKey('manage_nodes_page_enable_auto_node_switching_button_key'),
title: S.current.enable_auto_node_switching,
value: isPow
? powNodeListViewModel!.enableAutomaticNodeSwitching
: nodeListViewModel!.enableAutomaticNodeSwitching,
onValueChange: (BuildContext context, bool value) {
if (isPow) {
powNodeListViewModel!.setEnableAutomaticNodeSwitching(value);
} else {
nodeListViewModel!.setEnableAutomaticNodeSwitching(value);
}
},
),
),
SizedBox(height: 8),
Observer(
builder: (BuildContext context) {
int itemsCount =
@@ -36,22 +36,22 @@ class OtherSettingsPage extends BasePage {
if (_otherSettingsViewModel.displayTransactionPriority)
_otherSettingsViewModel.walletType == WalletType.bitcoin ?
SettingsPriorityPickerCell(
title: S.current.settings_fee_priority,
items: priorityForWalletType(_otherSettingsViewModel.walletType),
displayItem: _otherSettingsViewModel.getDisplayBitcoinPriority,
selectedItem: _otherSettingsViewModel.transactionPriority,
customItemIndex: _otherSettingsViewModel.customPriorityItemIndex,
onItemSelected: _otherSettingsViewModel.onDisplayBitcoinPrioritySelected,
customValue: _otherSettingsViewModel.customBitcoinFeeRate,
maxValue: _otherSettingsViewModel.maxCustomFeeRate?.toDouble(),
title: S.current.settings_fee_priority,
items: priorityForWalletType(_otherSettingsViewModel.walletType),
displayItem: _otherSettingsViewModel.getDisplayBitcoinPriority,
selectedItem: _otherSettingsViewModel.transactionPriority,
customItemIndex: _otherSettingsViewModel.customPriorityItemIndex,
onItemSelected: _otherSettingsViewModel.onDisplayBitcoinPrioritySelected,
customValue: _otherSettingsViewModel.customBitcoinFeeRate,
maxValue: _otherSettingsViewModel.maxCustomFeeRate?.toDouble(),
) :
SettingsPickerCell(
title: S.current.settings_fee_priority,
items: priorityForWalletType(_otherSettingsViewModel.walletType),
displayItem: _otherSettingsViewModel.getDisplayPriority,
selectedItem: _otherSettingsViewModel.transactionPriority,
onItemSelected: _otherSettingsViewModel.onDisplayPrioritySelected,
),
title: S.current.settings_fee_priority,
items: priorityForWalletType(_otherSettingsViewModel.walletType),
displayItem: _otherSettingsViewModel.getDisplayPriority,
selectedItem: _otherSettingsViewModel.transactionPriority,
onItemSelected: _otherSettingsViewModel.onDisplayPrioritySelected,
),
if (_otherSettingsViewModel.changeRepresentativeEnabled)
SettingsCellWithArrow(
title: S.current.change_rep,
+16
View File
@@ -128,6 +128,7 @@ abstract class SettingsStoreBase with Store {
required this.mwebEnabled,
required this.hasEnabledMwebBefore,
required this.mwebNodeUri,
required bool initialEnableAutomaticNodeSwitching,
TransactionPriority? initialBitcoinTransactionPriority,
TransactionPriority? initialMoneroTransactionPriority,
TransactionPriority? initialWowneroTransactionPriority,
@@ -186,6 +187,7 @@ abstract class SettingsStoreBase with Store {
currentSyncMode = initialSyncMode,
currentSyncAll = initialSyncAll,
currentBuiltinTor = initialBuiltinTor,
enableAutomaticNodeSwitching = initialEnableAutomaticNodeSwitching,
priority = ObservableMap<WalletType, TransactionPriority>() {
//this.nodes = ObservableMap<WalletType, Node>.of(nodes);
@@ -613,6 +615,11 @@ abstract class SettingsStoreBase with Store {
(_) => mwebNodeUri,
(String mwebNodeUri) =>
_sharedPreferences.setString(PreferencesKey.mwebNodeUri, mwebNodeUri));
reaction(
(_) => enableAutomaticNodeSwitching,
(bool enableAutomaticNodeSwitching) => _sharedPreferences.setBool(
PreferencesKey.enableAutomaticNodeSwitching, enableAutomaticNodeSwitching));
this.nodes.observe((change) {
if (change.newValue != null && change.key != null) {
@@ -859,6 +866,9 @@ abstract class SettingsStoreBase with Store {
@observable
String mwebNodeUri;
@observable
bool enableAutomaticNodeSwitching;
final SecureStorage _secureStorage;
final SharedPreferences _sharedPreferences;
@@ -1034,6 +1044,8 @@ abstract class SettingsStoreBase with Store {
sharedPreferences.getBool(PreferencesKey.hasEnabledMwebBefore) ?? false;
final mwebNodeUri = sharedPreferences.getString(PreferencesKey.mwebNodeUri) ??
"ltc-electrum.cakewallet.com:9333";
final enableAutomaticNodeSwitching =
sharedPreferences.getBool(PreferencesKey.enableAutomaticNodeSwitching) ?? true;
// If no value
if (pinLength == null || pinLength == 0) {
@@ -1335,6 +1347,7 @@ abstract class SettingsStoreBase with Store {
mwebEnabled: mwebEnabled,
mwebNodeUri: mwebNodeUri,
hasEnabledMwebBefore: hasEnabledMwebBefore,
initialEnableAutomaticNodeSwitching: enableAutomaticNodeSwitching,
initialMoneroTransactionPriority: moneroTransactionPriority,
initialWowneroTransactionPriority: wowneroTransactionPriority,
initialZanoTransactionPriority: zanoTransactionPriority,
@@ -1478,6 +1491,9 @@ abstract class SettingsStoreBase with Store {
actionlistDisplayMode = ObservableList<ActionListDisplayMode>();
actionlistDisplayMode.addAll(deserializeActionlistDisplayModes(
sharedPreferences.getInt(PreferencesKey.displayActionListModeKey) ?? defaultActionsMode));
enableAutomaticNodeSwitching =
sharedPreferences.getBool(PreferencesKey.enableAutomaticNodeSwitching) ??
enableAutomaticNodeSwitching;
var pinLength = sharedPreferences.getInt(PreferencesKey.currentPinLength);
// If no value
if (pinLength == null || pinLength == 0) {
@@ -26,6 +26,7 @@ abstract class NodeCreateOrEditViewModelBase with Store {
login = '',
password = '',
trusted = false,
isEnabledForAutoSwitching = false,
useSocksProxy = false,
socksProxyAddress = '';
@@ -56,6 +57,9 @@ abstract class NodeCreateOrEditViewModelBase with Store {
@observable
bool trusted;
@observable
bool isEnabledForAutoSwitching;
@observable
bool useSocksProxy;
@@ -118,6 +122,7 @@ abstract class NodeCreateOrEditViewModelBase with Store {
password = '';
useSSL = false;
trusted = false;
isEnabledForAutoSwitching = false;
useSocksProxy = false;
socksProxyAddress = '';
}
@@ -143,6 +148,9 @@ abstract class NodeCreateOrEditViewModelBase with Store {
@action
void setTrusted(bool val) => trusted = val;
@action
void setIsEnabledForAutoSwitching(bool val) => isEnabledForAutoSwitching = val;
@action
void setSocksProxy(bool val) => useSocksProxy = val;
@@ -159,6 +167,7 @@ abstract class NodeCreateOrEditViewModelBase with Store {
password: password,
useSSL: useSSL,
trusted: trusted,
isEnabledForAutoSwitching: isEnabledForAutoSwitching,
socksProxyAddress: socksProxyAddress);
try {
state = IsExecutingState();
@@ -190,6 +199,7 @@ abstract class NodeCreateOrEditViewModelBase with Store {
password: password,
useSSL: useSSL,
trusted: trusted,
isEnabledForAutoSwitching: isEnabledForAutoSwitching,
socksProxyAddress: socksProxyAddress);
try {
connectionState = IsExecutingState();
@@ -41,6 +41,13 @@ abstract class NodeListViewModelBase with Store {
S.current.change_current_node(uri) +
'${uri.endsWith('.onion') || uri.contains('.onion:') ? '\n' + (CakeTor.instance.enabled ? '' : S.current.orbot_running_alert) : ''}';
@computed
bool get enableAutomaticNodeSwitching => settingsStore.enableAutomaticNodeSwitching;
@action
void setEnableAutomaticNodeSwitching(bool value) =>
settingsStore.enableAutomaticNodeSwitching = value;
final ObservableList<Node> nodes;
final SettingsStore settingsStore;
final Box<Node> _nodeSource;
@@ -41,6 +41,13 @@ abstract class PowNodeListViewModelBase with Store {
S.current.change_current_node(uri) +
'${uri.endsWith('.onion') || uri.contains('.onion:') ? '\n' + (CakeTor.instance.enabled ? '' : S.current.orbot_running_alert) : ''}';
@computed
bool get enableAutomaticNodeSwitching => settingsStore.enableAutomaticNodeSwitching;
@action
void setEnableAutomaticNodeSwitching(bool value) =>
settingsStore.enableAutomaticNodeSwitching = value;
final ObservableList<Node> nodes;
final SettingsStore settingsStore;
final Box<Node> _nodeSource;