diff --git a/miner-app/lib/features/miner/miner_app_bar.dart b/miner-app/lib/features/miner/miner_app_bar.dart index 0d817bfc..7b8f3ca0 100644 --- a/miner-app/lib/features/miner/miner_app_bar.dart +++ b/miner-app/lib/features/miner/miner_app_bar.dart @@ -53,7 +53,9 @@ class _MinerAppBarState extends State { } void _goToSettingScreen() { - Navigator.of(context).push(MaterialPageRoute(builder: (context) => const SettingsScreen())); + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (context) => const SettingsScreen())); } @override @@ -64,17 +66,31 @@ class _MinerAppBarState extends State { floating: true, pinned: false, flexibleSpace: ClipRRect( - borderRadius: const BorderRadius.only(bottomLeft: Radius.circular(24), bottomRight: Radius.circular(24)), + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(24), + bottomRight: Radius.circular(24), + ), child: BackdropFilter( - filter: ColorFilter.mode(Colors.black.useOpacity(0.1), BlendMode.srcOver), + filter: ColorFilter.mode( + Colors.black.useOpacity(0.1), + BlendMode.srcOver, + ), child: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [Colors.white.useOpacity(0.1), Colors.white.useOpacity(0.05)], + colors: [ + Colors.white.useOpacity(0.1), + Colors.white.useOpacity(0.05), + ], + ), + border: Border( + bottom: BorderSide( + color: Colors.white.useOpacity(0.1), + width: 1, + ), ), - border: Border(bottom: BorderSide(color: Colors.white.useOpacity(0.1), width: 1)), ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), @@ -100,11 +116,16 @@ class _MinerAppBarState extends State { decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), color: Colors.white.useOpacity(0.1), - border: Border.all(color: Colors.white.useOpacity(0.2), width: 1), + border: Border.all( + color: Colors.white.useOpacity(0.2), + width: 1, + ), ), child: PopupMenuButton<_MenuValues>( color: const Color(0xFF1A1A1A), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), onSelected: (_MenuValues item) async { switch (item) { case _MenuValues.logout: @@ -115,35 +136,57 @@ class _MinerAppBarState extends State { break; } }, - itemBuilder: (BuildContext context) => >[ - PopupMenuItem<_MenuValues>( - value: _MenuValues.logout, - child: Row( - children: [ - Icon(Icons.logout, color: Colors.red.useOpacity(0.8), size: 20), - const SizedBox(width: 12), - Text( - 'Logout (Full Reset)', - style: TextStyle(color: Colors.white.useOpacity(0.9), fontSize: 14), + itemBuilder: (BuildContext context) => + >[ + PopupMenuItem<_MenuValues>( + value: _MenuValues.logout, + child: Row( + children: [ + Icon( + Icons.logout, + color: Colors.red.useOpacity(0.8), + size: 20, + ), + const SizedBox(width: 12), + Text( + 'Logout (Full Reset)', + style: TextStyle( + color: Colors.white.useOpacity(0.9), + fontSize: 14, + ), + ), + ], ), - ], - ), - ), - PopupMenuItem<_MenuValues>( - value: _MenuValues.setting, - child: Row( - children: [ - Icon(Icons.settings, color: Colors.grey.useOpacity(0.8), size: 20), - const SizedBox(width: 12), - Text('Settings', style: TextStyle(color: Colors.white.useOpacity(0.9), fontSize: 14)), - ], - ), - ), - ], + ), + PopupMenuItem<_MenuValues>( + value: _MenuValues.setting, + child: Row( + children: [ + Icon( + Icons.settings, + color: Colors.grey.useOpacity(0.8), + size: 20, + ), + const SizedBox(width: 12), + Text( + 'Settings', + style: TextStyle( + color: Colors.white.useOpacity(0.9), + fontSize: 14, + ), + ), + ], + ), + ), + ], child: Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Icon(Icons.menu, color: Colors.white.useOpacity(0.7), size: 20), + child: Icon( + Icons.menu, + color: Colors.white.useOpacity(0.7), + size: 20, + ), ), ), ), diff --git a/miner-app/lib/features/miner/miner_balance_card.dart b/miner-app/lib/features/miner/miner_balance_card.dart index 36d8203e..011a2881 100644 --- a/miner-app/lib/features/miner/miner_balance_card.dart +++ b/miner-app/lib/features/miner/miner_balance_card.dart @@ -2,13 +2,23 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:polkadart/polkadart.dart'; +import 'package:quantus_miner/src/config/miner_config.dart'; import 'package:quantus_miner/src/services/binary_manager.dart'; +import 'package:quantus_miner/src/services/miner_settings_service.dart'; import 'package:quantus_miner/src/shared/extensions/snackbar_extensions.dart'; import 'package:quantus_miner/src/shared/miner_app_constants.dart'; +import 'package:quantus_miner/src/utils/app_logger.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:quantus_sdk/generated/schrodinger/schrodinger.dart'; + +final _log = log.withTag('BalanceCard'); class MinerBalanceCard extends StatefulWidget { - const MinerBalanceCard({super.key}); + /// Current block number - when this changes, balance is refreshed + final int currentBlock; + + const MinerBalanceCard({super.key, this.currentBlock = 0}); @override State createState() => _MinerBalanceCardState(); @@ -17,28 +27,48 @@ class MinerBalanceCard extends StatefulWidget { class _MinerBalanceCardState extends State { String _walletBalance = 'Loading...'; String? _walletAddress; + String _chainId = MinerConfig.defaultChainId; Timer? _balanceTimer; + final _settingsService = MinerSettingsService(); + int _lastRefreshedBlock = 0; @override void initState() { super.initState(); - _fetchWalletBalance(); - // Start automatic polling every 30 seconds - _balanceTimer = Timer.periodic(const Duration(seconds: 30), (_) { - _fetchWalletBalance(); + _loadChainAndFetchBalance(); + // Start automatic polling as backup + _balanceTimer = Timer.periodic(MinerConfig.balancePollingInterval, (_) { + _loadChainAndFetchBalance(); }); } + @override + void didUpdateWidget(MinerBalanceCard oldWidget) { + super.didUpdateWidget(oldWidget); + // Refresh balance when block number increases (new block found) + if (widget.currentBlock > _lastRefreshedBlock && widget.currentBlock > 0) { + _lastRefreshedBlock = widget.currentBlock; + _loadChainAndFetchBalance(); + } + } + @override void dispose() { _balanceTimer?.cancel(); super.dispose(); } + Future _loadChainAndFetchBalance() async { + final chainId = await _settingsService.getChainId(); + if (mounted) { + setState(() => _chainId = chainId); + } + await _fetchWalletBalance(); + } + Future _fetchWalletBalance() async { - // Implement actual wallet balance fetching using quantus_sdk - print('fetching wallet balance'); + _log.d('Fetching wallet balance for chain: $_chainId'); try { final quantusHome = await BinaryManager.getQuantusHomeDirectoryPath(); final rewardsFile = File('$quantusHome/rewards-address.txt'); @@ -47,42 +77,86 @@ class _MinerBalanceCardState extends State { final address = (await rewardsFile.readAsString()).trim(); if (address.isNotEmpty) { - print('address: $address'); + final chainConfig = MinerConfig.getChainById(_chainId); + _log.d( + 'Chain: ${chainConfig.id}, rpcUrl: ${chainConfig.rpcUrl}, isLocal: ${chainConfig.isLocalNode}', + ); + BigInt balance; - // Fetch balance using SubstrateService (exported by quantus_sdk) - final balance = await SubstrateService().queryBalance(address); + if (chainConfig.isLocalNode) { + // Use local node RPC for dev chain + _log.d('Querying balance from local node: ${chainConfig.rpcUrl}'); + balance = await _queryBalanceFromLocalNode( + address, + chainConfig.rpcUrl, + ); + } else { + // Use SDK's SubstrateService for remote chains (dirac) + _log.d('Querying balance from remote (SDK SubstrateService)'); + balance = await SubstrateService().queryBalance(address); + } - print('balance: $balance'); + _log.d('Balance: $balance'); - setState(() { - // Assuming NumberFormattingService and AppConstants are available via quantus_sdk export - _walletBalance = NumberFormattingService().formatBalance(balance, addSymbol: true); - _walletAddress = address; - }); + if (mounted) { + setState(() { + _walletBalance = NumberFormattingService().formatBalance( + balance, + addSymbol: true, + ); + _walletAddress = address; + }); + } } else { - // Address file exists but is empty _handleAddressNotSet(); } } else { - // Address file does not exist _handleAddressNotSet(); } } catch (e) { - setState(() { - _walletBalance = 'Error fetching balance'; - }); - print('Error fetching wallet balance: $e'); + if (mounted) { + setState(() { + // Show helpful message for dev chain when node not running + if (_chainId == 'dev') { + _walletBalance = 'Start node to view'; + } else { + _walletBalance = 'Error'; + } + }); + } + _log.w('Error fetching wallet balance', error: e); + } + } + + /// Query balance directly from local node using Polkadart + Future _queryBalanceFromLocalNode( + String address, + String rpcUrl, + ) async { + try { + final provider = Provider.fromUri(Uri.parse(rpcUrl)); + final quantusApi = Schrodinger(provider); + + // Convert SS58 address to account ID using the SDK's crypto + final accountId = ss58ToAccountId(s: address); + + final accountInfo = await quantusApi.query.system.account(accountId); + return accountInfo.data.free; + } catch (e) { + _log.d('Error querying local node balance: $e'); + // Return zero if node is not running or address has no balance + return BigInt.zero; } } void _handleAddressNotSet() { - setState(() { - _walletBalance = 'Address not set'; - _walletAddress = null; - }); - print('Rewards address file not found or empty.'); - // Example Navigation (requires go_router setup) - // context.go('/rewards_address_setup'); + if (mounted) { + setState(() { + _walletBalance = 'Address not set'; + _walletAddress = null; + }); + } + _log.w('Rewards address file not found or empty'); } @override @@ -99,7 +173,12 @@ class _MinerBalanceCardState extends State { borderRadius: BorderRadius.circular(24), border: Border.all(color: Colors.white.useOpacity(0.1), width: 1), boxShadow: [ - BoxShadow(color: Colors.black.useOpacity(0.2), blurRadius: 20, spreadRadius: 1, offset: const Offset(0, 8)), + BoxShadow( + color: Colors.black.useOpacity(0.2), + blurRadius: 20, + spreadRadius: 1, + offset: const Offset(0, 8), + ), ], ), child: Padding( @@ -120,12 +199,20 @@ class _MinerBalanceCardState extends State { ), borderRadius: BorderRadius.circular(12), ), - child: const Icon(Icons.account_balance_wallet, color: Colors.white, size: 20), + child: const Icon( + Icons.account_balance_wallet, + color: Colors.white, + size: 20, + ), ), const SizedBox(width: 12), Text( 'Wallet Balance', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white.useOpacity(0.9)), + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.white.useOpacity(0.9), + ), ), ], ), @@ -146,11 +233,18 @@ class _MinerBalanceCardState extends State { decoration: BoxDecoration( color: Colors.white.useOpacity(0.05), borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.white.useOpacity(0.1), width: 1), + border: Border.all( + color: Colors.white.useOpacity(0.1), + width: 1, + ), ), child: Row( children: [ - Icon(Icons.link, color: Colors.white.useOpacity(0.5), size: 16), + Icon( + Icons.link, + color: Colors.white.useOpacity(0.5), + size: 16, + ), const SizedBox(width: 8), Expanded( child: Text( @@ -164,7 +258,11 @@ class _MinerBalanceCardState extends State { ), ), IconButton( - icon: Icon(Icons.copy, color: Colors.white.useOpacity(0.5), size: 16), + icon: Icon( + Icons.copy, + color: Colors.white.useOpacity(0.5), + size: 16, + ), onPressed: () { if (_walletAddress != null) { context.copyTextWithSnackbar(_walletAddress!); diff --git a/miner-app/lib/features/miner/miner_controls.dart b/miner-app/lib/features/miner/miner_controls.dart index bf8a3e9e..1e7fba8f 100644 --- a/miner-app/lib/features/miner/miner_controls.dart +++ b/miner-app/lib/features/miner/miner_controls.dart @@ -2,27 +2,29 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:quantus_miner/src/config/miner_config.dart'; +import 'package:quantus_miner/src/services/mining_orchestrator.dart'; import 'package:quantus_miner/src/services/mining_stats_service.dart'; import 'package:quantus_miner/src/shared/extensions/snackbar_extensions.dart'; +import 'package:quantus_miner/src/utils/app_logger.dart'; import '../../main.dart'; import '../../src/services/binary_manager.dart'; import '../../src/services/gpu_detection_service.dart'; -import '../../src/services/miner_process.dart'; import '../../src/services/miner_settings_service.dart'; +final _log = log.withTag('MinerControls'); + class MinerControls extends StatefulWidget { - final MinerProcess? minerProcess; + final MiningOrchestrator? orchestrator; final MiningStats miningStats; - final Function(MiningStats) onMetricsUpdate; - final Function(MinerProcess?) onMinerProcessChanged; + final Function(MiningOrchestrator?) onOrchestratorChanged; const MinerControls({ super.key, - required this.minerProcess, + required this.orchestrator, required this.miningStats, - required this.onMetricsUpdate, - required this.onMinerProcessChanged, + required this.onOrchestratorChanged, }); @override @@ -30,10 +32,12 @@ class MinerControls extends StatefulWidget { } class _MinerControlsState extends State { - bool _isAttemptingToggle = false; + bool _isNodeToggling = false; + bool _isMinerToggling = false; int _cpuWorkers = 8; int _gpuDevices = 0; int _detectedGpuCount = 0; + String _chainId = MinerConfig.defaultChainId; final _settingsService = MinerSettingsService(); @override @@ -46,11 +50,15 @@ class _MinerControlsState extends State { Future _loadSettings() async { final savedCpuWorkers = await _settingsService.getCpuWorkers(); final savedGpuDevices = await _settingsService.getGpuDevices(); + final savedChainId = await _settingsService.getChainId(); if (mounted) { setState(() { - _cpuWorkers = savedCpuWorkers ?? (Platform.numberOfProcessors > 0 ? Platform.numberOfProcessors : 8); + _cpuWorkers = + savedCpuWorkers ?? + (Platform.numberOfProcessors > 0 ? Platform.numberOfProcessors : 8); _gpuDevices = savedGpuDevices ?? 0; + _chainId = savedChainId; }); } } @@ -64,113 +72,225 @@ class _MinerControlsState extends State { } } - Future _toggle() async { - if (_isAttemptingToggle) return; - setState(() => _isAttemptingToggle = true); - - if (widget.minerProcess == null) { - print('Starting mining'); - - // Check for all required files and binaries - final id = File('${await BinaryManager.getQuantusHomeDirectoryPath()}/node_key.p2p'); - final rew = File('${await BinaryManager.getQuantusHomeDirectoryPath()}/rewards-address.txt'); - final binPath = await BinaryManager.getNodeBinaryFilePath(); - final bin = File(binPath); - final minerBinPath = await BinaryManager.getExternalMinerBinaryFilePath(); - final minerBin = File(minerBinPath); - - // Check node binary - if (!await bin.exists()) { - print('Node binary not found. Cannot start mining.'); - if (mounted) { - context.showWarningSnackbar(title: 'Node binary not found!', message: 'Please run setup.'); - } - setState(() => _isAttemptingToggle = false); - return; - } + // ============================================================ + // Node Control + // ============================================================ + + Future _toggleNode() async { + if (_isNodeToggling) return; + setState(() => _isNodeToggling = true); + + if (!_isNodeRunning) { + await _startNode(); + } else { + await _stopNode(); + } + + if (mounted) { + setState(() => _isNodeToggling = false); + } + } + + Future _startNode() async { + _log.i('Starting node'); + + // Reload chain ID in case it was changed in settings + final chainId = await _settingsService.getChainId(); + if (mounted) { + setState(() => _chainId = chainId); + } - // Check external miner binary - if (!await minerBin.exists()) { - print('External miner binary not found. Cannot start mining.'); - if (mounted) { - context.showWarningSnackbar(title: 'External miner binary not found!', message: 'Please run setup.'); - } - setState(() => _isAttemptingToggle = false); - return; + // Check for required files + final quantusHome = await BinaryManager.getQuantusHomeDirectoryPath(); + final identityFile = File('$quantusHome/node_key.p2p'); + final rewardsFile = File('$quantusHome/rewards-address.txt'); + final nodeBinPath = await BinaryManager.getNodeBinaryFilePath(); + final nodeBin = File(nodeBinPath); + final minerBinPath = await BinaryManager.getExternalMinerBinaryFilePath(); + final minerBin = File(minerBinPath); + + if (!await nodeBin.exists()) { + _log.w('Node binary not found'); + if (mounted) { + context.showWarningSnackbar( + title: 'Node binary not found!', + message: 'Please run setup.', + ); } + return; + } - final newProc = MinerProcess( - bin, - id, - rew, - onStatsUpdate: widget.onMetricsUpdate, - cpuWorkers: _cpuWorkers, - gpuDevices: _gpuDevices, - detectedGpuCount: _detectedGpuCount, + // Create new orchestrator + final orchestrator = MiningOrchestrator(); + widget.onOrchestratorChanged(orchestrator); + + try { + await orchestrator.startNode( + MiningSessionConfig( + nodeBinary: nodeBin, + minerBinary: minerBin, + identityFile: identityFile, + rewardsFile: rewardsFile, + chainId: _chainId, + cpuWorkers: _cpuWorkers, + gpuDevices: _gpuDevices, + detectedGpuCount: _detectedGpuCount, + ), ); - // Notify parent about the new miner process - widget.onMinerProcessChanged.call(newProc); + } catch (e) { + _log.e('Error starting node', error: e); + if (mounted) { + context.showErrorSnackbar( + title: 'Error starting node!', + message: e.toString(), + ); + } + orchestrator.dispose(); + widget.onOrchestratorChanged(null); + } + } + Future _stopNode() async { + _log.i('Stopping node'); + + if (widget.orchestrator != null) { try { - final newMiningStats = widget.miningStats.copyWith(isSyncing: true, status: MiningStatus.syncing); - widget.onMetricsUpdate(newMiningStats); - await newProc.start(); + await widget.orchestrator!.stopNode(); } catch (e) { - print('Error starting miner process: $e'); - if (mounted) { - context.showErrorSnackbar(title: 'Error starting miner!', message: e.toString()); - } - - // Notify parent that miner process is null - widget.onMinerProcessChanged.call(null); - final newMiningStats = MiningStats.empty(); - widget.onMetricsUpdate(newMiningStats); + _log.e('Error stopping node', error: e); } + widget.orchestrator!.dispose(); + } + + await GlobalMinerManager.cleanup(); + widget.onOrchestratorChanged(null); + } + + // ============================================================ + // Miner Control + // ============================================================ + + Future _toggleMiner() async { + if (_isMinerToggling) return; + setState(() => _isMinerToggling = true); + + if (!_isMining) { + await _startMiner(); } else { - print('Stopping mining'); + await _stopMiner(); + } - try { - widget.minerProcess!.stop(); - // Wait a moment for graceful shutdown - await Future.delayed(const Duration(seconds: 1)); - } catch (e) { - print('Error during graceful stop: $e'); + if (mounted) { + setState(() => _isMinerToggling = false); + } + } + + Future _startMiner() async { + _log.i('Starting miner'); + + if (widget.orchestrator == null) { + if (mounted) { + context.showWarningSnackbar( + title: 'Node not running!', + message: 'Start the node first.', + ); } + return; + } - await GlobalMinerManager.cleanup(); + // Check miner binary exists + final minerBinPath = await BinaryManager.getExternalMinerBinaryFilePath(); + final minerBin = File(minerBinPath); - // Notify parent that miner process is stopped - widget.onMinerProcessChanged.call(null); - final newMiningStats = MiningStats.empty(); - widget.onMetricsUpdate(newMiningStats); + if (!await minerBin.exists()) { + _log.w('Miner binary not found'); + if (mounted) { + context.showWarningSnackbar( + title: 'Miner binary not found!', + message: 'Please run setup.', + ); + } + return; } - if (mounted) { - setState(() => _isAttemptingToggle = false); + + try { + // Update settings in case they changed while miner was stopped + widget.orchestrator!.updateMinerSettings( + cpuWorkers: _cpuWorkers, + gpuDevices: _gpuDevices, + ); + + await widget.orchestrator!.startMiner(); + } catch (e) { + _log.e('Error starting miner', error: e); + if (mounted) { + context.showErrorSnackbar( + title: 'Error starting miner!', + message: e.toString(), + ); + } } } - @override - void dispose() { - // _poll?.cancel(); // _poll removed - if (widget.minerProcess != null) { - print('MinerControls: disposing, force stopping miner process'); + Future _stopMiner() async { + _log.i('Stopping miner'); + if (widget.orchestrator != null) { try { - widget.minerProcess!.forceStop(); + await widget.orchestrator!.stopMiner(); } catch (e) { - print('MinerControls: Error force stopping miner process in dispose: $e'); + _log.e('Error stopping miner', error: e); } + } + } - // Use GlobalMinerManager for comprehensive cleanup - GlobalMinerManager.cleanup(); + // ============================================================ + // State Helpers + // ============================================================ - widget.onMinerProcessChanged.call(null); - } - super.dispose(); + bool get _isNodeRunning => widget.orchestrator?.isNodeRunning ?? false; + bool get _isMining => widget.orchestrator?.isMining ?? false; + + /// Whether miner is starting or running (for disabling settings) + bool get _isMinerActive { + final state = widget.orchestrator?.state; + return state == MiningState.startingMiner || + state == MiningState.mining || + state == MiningState.stoppingMiner; + } + + String get _nodeButtonText { + final state = widget.orchestrator?.state; + if (state == MiningState.startingNode) return 'Starting...'; + if (state == MiningState.waitingForRpc) return 'Connecting...'; + if (_isNodeRunning) return 'Stop Node'; + return 'Start Node'; + } + + String get _minerButtonText { + final state = widget.orchestrator?.state; + if (state == MiningState.startingMiner) return 'Starting...'; + if (state == MiningState.stoppingMiner) return 'Stopping...'; + if (_isMining) return 'Stop Mining'; + return 'Start Mining'; + } + + Color get _nodeButtonColor { + if (_isNodeRunning) return Colors.orange; + return Colors.blue; + } + + Color get _minerButtonColor { + if (_isMining) return Colors.red; + return Colors.green; } @override Widget build(BuildContext context) { + // Allow editing settings when miner is stopped (even if node is running) + // Disable during startingMiner, mining, and stoppingMiner states + final canEditSettings = !_isMinerActive; + return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -183,17 +303,26 @@ class _MinerControlsState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text('CPU Workers', style: TextStyle(fontWeight: FontWeight.bold)), + const Text( + 'CPU Workers', + style: TextStyle(fontWeight: FontWeight.bold), + ), Text('$_cpuWorkers'), ], ), Slider( value: _cpuWorkers.toDouble(), min: 0, - max: (Platform.numberOfProcessors > 0 ? Platform.numberOfProcessors : 16).toDouble(), - divisions: (Platform.numberOfProcessors > 0 ? Platform.numberOfProcessors : 16), + max: + (Platform.numberOfProcessors > 0 + ? Platform.numberOfProcessors + : 16) + .toDouble(), + divisions: (Platform.numberOfProcessors > 0 + ? Platform.numberOfProcessors + : 16), label: _cpuWorkers.toString(), - onChanged: widget.minerProcess == null + onChanged: canEditSettings ? (value) { final rounded = value.round(); setState(() => _cpuWorkers = rounded); @@ -205,6 +334,7 @@ class _MinerControlsState extends State { ), ), const SizedBox(height: 16), + // GPU Devices Control Padding( padding: const EdgeInsets.symmetric(horizontal: 24.0), @@ -214,7 +344,10 @@ class _MinerControlsState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text('GPU Devices', style: TextStyle(fontWeight: FontWeight.bold)), + const Text( + 'GPU Devices', + style: TextStyle(fontWeight: FontWeight.bold), + ), Text('$_gpuDevices / $_detectedGpuCount'), ], ), @@ -224,7 +357,7 @@ class _MinerControlsState extends State { max: _detectedGpuCount > 0 ? _detectedGpuCount.toDouble() : 1, divisions: _detectedGpuCount > 0 ? _detectedGpuCount : 1, label: _gpuDevices.toString(), - onChanged: widget.minerProcess == null + onChanged: canEditSettings ? (value) { final rounded = value.round(); setState(() => _gpuDevices = rounded); @@ -236,16 +369,60 @@ class _MinerControlsState extends State { ), ), const SizedBox(height: 24), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: widget.minerProcess == null ? Colors.green : Colors.blue, - padding: const EdgeInsets.symmetric(vertical: 15), - textStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - minimumSize: const Size(200, 50), - ), - onPressed: _isAttemptingToggle ? null : _toggle, - child: Text(widget.minerProcess == null ? 'Start Mining' : 'Stop Mining'), + + // Control Buttons + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Node Button + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: _nodeButtonColor, + padding: const EdgeInsets.symmetric( + vertical: 15, + horizontal: 20, + ), + textStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + minimumSize: const Size(140, 50), + ), + onPressed: _isNodeToggling ? null : _toggleNode, + child: Text(_nodeButtonText), + ), + const SizedBox(width: 16), + + // Miner Button + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: _minerButtonColor, + padding: const EdgeInsets.symmetric( + vertical: 15, + horizontal: 20, + ), + textStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + minimumSize: const Size(140, 50), + ), + onPressed: (_isMinerToggling || !_isNodeRunning) + ? null + : _toggleMiner, + child: Text(_minerButtonText), + ), + ], ), + + // Status indicator + if (_isNodeRunning && !_isMining) ...[ + const SizedBox(height: 12), + Text( + 'Node running - ready to mine', + style: TextStyle(color: Colors.green.shade300, fontSize: 12), + ), + ], ], ); } diff --git a/miner-app/lib/features/miner/miner_dashboard_screen.dart b/miner-app/lib/features/miner/miner_dashboard_screen.dart index aac149c2..7247e283 100644 --- a/miner-app/lib/features/miner/miner_dashboard_screen.dart +++ b/miner-app/lib/features/miner/miner_dashboard_screen.dart @@ -5,8 +5,9 @@ import 'package:quantus_miner/features/miner/miner_balance_card.dart'; import 'package:quantus_miner/features/miner/miner_app_bar.dart'; import 'package:quantus_miner/features/miner/miner_stats_card.dart'; import 'package:quantus_miner/features/miner/miner_status.dart'; +import 'package:quantus_miner/src/models/miner_error.dart'; import 'package:quantus_miner/src/services/binary_manager.dart'; -import 'package:quantus_miner/src/services/miner_process.dart'; +import 'package:quantus_miner/src/services/mining_orchestrator.dart'; import 'package:quantus_miner/src/services/mining_stats_service.dart'; import 'package:quantus_miner/src/shared/extensions/snackbar_extensions.dart'; import 'package:quantus_miner/src/ui/logs_widget.dart'; @@ -34,25 +35,32 @@ class _MinerDashboardScreenState extends State { Timer? _nodePollingTimer; MiningStats _miningStats = MiningStats.empty(); - MinerProcess? _currentMinerProcess; + + // The orchestrator manages all mining operations + MiningOrchestrator? _orchestrator; + + // Subscriptions + StreamSubscription? _statsSubscription; + StreamSubscription? _errorSubscription; + StreamSubscription? _stateSubscription; @override void initState() { super.initState(); - _initializeNodeUpdatePolling(); _initializeMinerUpdatePolling(); } @override void dispose() { - // Clean up global miner process - if (_currentMinerProcess != null) { - try { - _currentMinerProcess!.forceStop(); - } catch (e) { - print('MinerDashboard: Error stopping miner process on dispose: $e'); - } + // Clean up subscriptions + _statsSubscription?.cancel(); + _errorSubscription?.cancel(); + _stateSubscription?.cancel(); + + // Clean up orchestrator + if (_orchestrator != null) { + _orchestrator!.forceStop(); } GlobalMinerManager.cleanup(); @@ -62,21 +70,72 @@ class _MinerDashboardScreenState extends State { super.dispose(); } - void _onMetricsUpdate(MiningStats miningStats) { - setState(() { - _miningStats = miningStats; - }); + void _onStatsUpdate(MiningStats stats) { + if (mounted) { + setState(() { + _miningStats = stats; + }); + } } - void _onMinerProcessChanged(MinerProcess? minerProcess) { + void _onOrchestratorChanged(MiningOrchestrator? orchestrator) { + // Cancel old subscriptions + _statsSubscription?.cancel(); + _errorSubscription?.cancel(); + _stateSubscription?.cancel(); + if (mounted) { setState(() { - _currentMinerProcess = minerProcess; + _orchestrator = orchestrator; }); } - // Register with global app lifecycle for cleanup - GlobalMinerManager.setMinerProcess(minerProcess); + // Set up new subscriptions + if (orchestrator != null) { + _statsSubscription = orchestrator.statsStream.listen(_onStatsUpdate); + _errorSubscription = orchestrator.errorStream.listen(_onError); + _stateSubscription = orchestrator.stateStream.listen(_onStateChange); + } + + // Register with global manager for cleanup + GlobalMinerManager.setOrchestrator(orchestrator); + } + + void _onStateChange(MiningState state) { + // Trigger rebuild when orchestrator state changes + // This ensures button labels and UI state update properly + if (mounted) { + setState(() {}); + } + } + + void _onError(MinerError error) { + if (!mounted) return; + + // Show error to user + context.showErrorSnackbar( + title: _getErrorTitle(error), + message: error.message, + ); + } + + String _getErrorTitle(MinerError error) { + switch (error.type) { + case MinerErrorType.minerCrashed: + return 'Miner Crashed'; + case MinerErrorType.nodeCrashed: + return 'Node Crashed'; + case MinerErrorType.minerStartupFailed: + return 'Miner Startup Failed'; + case MinerErrorType.nodeStartupFailed: + return 'Node Startup Failed'; + case MinerErrorType.metricsConnectionLost: + return 'Metrics Connection Lost'; + case MinerErrorType.rpcConnectionLost: + return 'RPC Connection Lost'; + case MinerErrorType.unknown: + return 'Error'; + } } void _initializeMinerUpdatePolling() { @@ -114,7 +173,7 @@ class _MinerDashboardScreenState extends State { } void _handleUpdateMiner() async { - if (_currentMinerProcess != null) { + if (_orchestrator?.isMining == true) { context.showErrorSnackbar( title: 'Miner is running!', message: 'To update the binary please stop the miner first.', @@ -126,7 +185,8 @@ class _MinerDashboardScreenState extends State { onProgress: (progress) { setState(() { if (progress.totalBytes > 0) { - _minerUpdateProgress = progress.downloadedBytes / progress.totalBytes; + _minerUpdateProgress = + progress.downloadedBytes / progress.totalBytes; } else { _minerUpdateProgress = progress.downloadedBytes > 0 ? 1.0 : 0.0; } @@ -176,7 +236,7 @@ class _MinerDashboardScreenState extends State { } void _handleUpdateNode() async { - if (_currentMinerProcess != null) { + if (_orchestrator?.isMining == true) { context.showErrorSnackbar( title: 'Miner is running!', message: 'To update the binary please stop the miner first.', @@ -188,7 +248,8 @@ class _MinerDashboardScreenState extends State { onProgress: (progress) { setState(() { if (progress.totalBytes > 0) { - _nodeUpdateProgress = progress.downloadedBytes / progress.totalBytes; + _nodeUpdateProgress = + progress.downloadedBytes / progress.totalBytes; } else { _nodeUpdateProgress = progress.downloadedBytes > 0 ? 1.0 : 0.0; } @@ -261,10 +322,9 @@ class _MinerDashboardScreenState extends State { child: SizedBox( width: double.infinity, child: MinerControls( - minerProcess: _currentMinerProcess, + orchestrator: _orchestrator, miningStats: _miningStats, - onMetricsUpdate: _onMetricsUpdate, - onMinerProcessChanged: _onMinerProcessChanged, + onOrchestratorChanged: _onOrchestratorChanged, ), ), ), @@ -280,13 +340,20 @@ class _MinerDashboardScreenState extends State { // Logs section SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.only(left: 20, right: 20, bottom: 20), + padding: const EdgeInsets.only( + left: 20, + right: 20, + bottom: 20, + ), child: Container( height: 430, decoration: BoxDecoration( color: Colors.white.useOpacity(0.05), borderRadius: BorderRadius.circular(20), - border: Border.all(color: Colors.white.useOpacity(0.1), width: 1), + border: Border.all( + color: Colors.white.useOpacity(0.1), + width: 1, + ), ), child: Column( children: [ @@ -294,11 +361,20 @@ class _MinerDashboardScreenState extends State { Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: Colors.white.useOpacity(0.1), width: 1)), + border: Border( + bottom: BorderSide( + color: Colors.white.useOpacity(0.1), + width: 1, + ), + ), ), child: Row( children: [ - Icon(Icons.terminal, color: Colors.white.useOpacity(0.7), size: 20), + Icon( + Icons.terminal, + color: Colors.white.useOpacity(0.7), + size: 20, + ), const SizedBox(width: 12), Text( 'Live Logs', @@ -312,7 +388,12 @@ class _MinerDashboardScreenState extends State { ), ), // Logs content - Expanded(child: LogsWidget(minerProcess: _currentMinerProcess, maxLines: 200)), + Expanded( + child: LogsWidget( + orchestrator: _orchestrator, + maxLines: 200, + ), + ), ], ), ), @@ -332,7 +413,11 @@ class _MinerDashboardScreenState extends State { if (constraints.maxWidth > 800) { return Row( children: [ - Expanded(child: MinerBalanceCard()), + Expanded( + child: MinerBalanceCard( + currentBlock: _miningStats.currentBlock, + ), + ), const SizedBox(width: 16), Expanded(child: MinerStatsCard(miningStats: _miningStats)), ], @@ -340,7 +425,7 @@ class _MinerDashboardScreenState extends State { } else { return Column( children: [ - MinerBalanceCard(), + MinerBalanceCard(currentBlock: _miningStats.currentBlock), MinerStatsCard(miningStats: _miningStats), ], ); diff --git a/miner-app/lib/features/miner/miner_stats_card.dart b/miner-app/lib/features/miner/miner_stats_card.dart index d9c9c3c3..b74d3a82 100644 --- a/miner-app/lib/features/miner/miner_stats_card.dart +++ b/miner-app/lib/features/miner/miner_stats_card.dart @@ -29,7 +29,10 @@ class _MinerStatsCardState extends State { return Container( padding: const EdgeInsets.all(40), margin: const EdgeInsets.only(bottom: 20), - decoration: BoxDecoration(color: Colors.white.useOpacity(0.05), borderRadius: BorderRadius.circular(20)), + decoration: BoxDecoration( + color: Colors.white.useOpacity(0.05), + borderRadius: BorderRadius.circular(20), + ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -38,11 +41,16 @@ class _MinerStatsCardState extends State { height: 20, child: CircularProgressIndicator( strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white.useOpacity(0.6)), + valueColor: AlwaysStoppedAnimation( + Colors.white.useOpacity(0.6), + ), ), ), const SizedBox(width: 16), - Text('Loading mining stats...', style: TextStyle(color: Colors.white.useOpacity(0.6), fontSize: 16)), + Text( + 'Loading mining stats...', + style: TextStyle(color: Colors.white.useOpacity(0.6), fontSize: 16), + ), ], ), ); @@ -61,7 +69,12 @@ class _MinerStatsCardState extends State { borderRadius: BorderRadius.circular(24), border: Border.all(color: Colors.white.useOpacity(0.1), width: 1), boxShadow: [ - BoxShadow(color: Colors.black.useOpacity(0.2), blurRadius: 20, spreadRadius: 1, offset: const Offset(0, 8)), + BoxShadow( + color: Colors.black.useOpacity(0.2), + blurRadius: 20, + spreadRadius: 1, + offset: const Offset(0, 8), + ), ], ), child: Padding( @@ -83,12 +96,20 @@ class _MinerStatsCardState extends State { ), borderRadius: BorderRadius.circular(14), ), - child: const Icon(Icons.analytics, color: Colors.white, size: 24), + child: const Icon( + Icons.analytics, + color: Colors.white, + size: 24, + ), ), const SizedBox(width: 16), Text( 'Mining Performance - ${_miningStats!.chainName}', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white.useOpacity(0.9)), + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.white.useOpacity(0.9), + ), ), ], ), @@ -100,12 +121,17 @@ class _MinerStatsCardState extends State { Expanded( child: Column( children: [ - _buildCompactStat(icon: Icons.people, label: 'Peers', value: '${_miningStats!.peerCount}'), + _buildCompactStat( + icon: Icons.people, + label: 'Peers', + value: '${_miningStats!.peerCount}', + ), const SizedBox(height: 16), _buildDualStat( icon: Icons.memory, label1: 'CPU', - value1: '${_miningStats!.workers} / ${_miningStats!.cpuCapacity}', + value1: + '${_miningStats!.workers} / ${_miningStats!.cpuCapacity}', label2: 'GPU', value2: '${_miningStats!.gpuDevices} / ${_miningStats!.gpuCapacity > 0 ? _miningStats!.gpuCapacity : (_miningStats!.gpuDevices > 0 ? _miningStats!.gpuDevices : "-")}', @@ -127,7 +153,8 @@ class _MinerStatsCardState extends State { _buildCompactStat( icon: Icons.block, label: 'Block', - value: '${_miningStats!.currentBlock} / ${_miningStats!.targetBlock}', + value: + '${_miningStats!.currentBlock} / ${_miningStats!.targetBlock}', ), ], ), @@ -197,7 +224,11 @@ class _MinerStatsCardState extends State { ], ), const SizedBox(width: 8), - Container(width: 1, height: 28, color: Colors.white.useOpacity(0.3)), + Container( + width: 1, + height: 28, + color: Colors.white.useOpacity(0.3), + ), const SizedBox(width: 8), Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -232,7 +263,11 @@ class _MinerStatsCardState extends State { ); } - Widget _buildCompactStat({required IconData icon, required String label, required String value}) { + Widget _buildCompactStat({ + required IconData icon, + required String label, + required String value, + }) { return Row( children: [ Container( diff --git a/miner-app/lib/features/miner/miner_status.dart b/miner-app/lib/features/miner/miner_status.dart index 5afabe5f..48d63b27 100644 --- a/miner-app/lib/features/miner/miner_status.dart +++ b/miner-app/lib/features/miner/miner_status.dart @@ -16,7 +16,10 @@ class MinerStatus extends StatelessWidget { case MiningStatus.idle: return _StatusConfig( icon: Icons.pause_circle_outline, - colors: [const Color(0xFF64748B), const Color(0xFF475569)], // Slate gray + colors: [ + const Color(0xFF64748B), + const Color(0xFF475569), + ], // Slate gray glowColor: const Color(0xFF64748B), label: 'IDLE', ); @@ -80,7 +83,8 @@ class _StatusBadge extends StatefulWidget { State<_StatusBadge> createState() => _StatusBadgeState(); } -class _StatusBadgeState extends State<_StatusBadge> with TickerProviderStateMixin { +class _StatusBadgeState extends State<_StatusBadge> + with TickerProviderStateMixin { late AnimationController _rotationController; late AnimationController _pulseController; late Animation _pulseAnimation; @@ -90,16 +94,21 @@ class _StatusBadgeState extends State<_StatusBadge> with TickerProviderStateMixi super.initState(); // Rotation animation for syncing - _rotationController = AnimationController(duration: const Duration(seconds: 2), vsync: this); + _rotationController = AnimationController( + duration: const Duration(seconds: 2), + vsync: this, + ); // Pickaxe animation for mining (arcing back and forth) - _pulseController = AnimationController(duration: const Duration(milliseconds: 800), vsync: this); + _pulseController = AnimationController( + duration: const Duration(milliseconds: 800), + vsync: this, + ); // Arc rotation: -30 degrees to +30 degrees (in radians) - _pulseAnimation = Tween( - begin: -0.5, - end: 0.5, - ).animate(CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut)); + _pulseAnimation = Tween(begin: -0.5, end: 0.5).animate( + CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), + ); _updateAnimations(); } @@ -152,7 +161,13 @@ class _StatusBadgeState extends State<_StatusBadge> with TickerProviderStateMixi end: Alignment.bottomRight, ), borderRadius: BorderRadius.circular(24), - boxShadow: [BoxShadow(color: widget.config.glowColor.useOpacity(0.4), blurRadius: 12, spreadRadius: 2)], + boxShadow: [ + BoxShadow( + color: widget.config.glowColor.useOpacity(0.4), + blurRadius: 12, + spreadRadius: 2, + ), + ], ), child: Row( mainAxisSize: MainAxisSize.min, @@ -164,8 +179,14 @@ class _StatusBadgeState extends State<_StatusBadge> with TickerProviderStateMixi ? (Matrix4.identity()..rotateZ(_pulseAnimation.value)) : Matrix4.identity(), child: RotationTransition( - turns: widget.config.isAnimated ? _rotationController : AlwaysStoppedAnimation(0), - child: Icon(widget.config.icon, color: Colors.white, size: 18), + turns: widget.config.isAnimated + ? _rotationController + : AlwaysStoppedAnimation(0), + child: Icon( + widget.config.icon, + color: Colors.white, + size: 18, + ), ), ), const SizedBox(width: 10), diff --git a/miner-app/lib/features/settings/settings_app_bar.dart b/miner-app/lib/features/settings/settings_app_bar.dart index 2402c167..323e0994 100644 --- a/miner-app/lib/features/settings/settings_app_bar.dart +++ b/miner-app/lib/features/settings/settings_app_bar.dart @@ -18,21 +18,37 @@ class _SettingsAppBarState extends State { floating: true, pinned: false, flexibleSpace: ClipRRect( - borderRadius: const BorderRadius.only(bottomLeft: Radius.circular(24), bottomRight: Radius.circular(24)), + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(24), + bottomRight: Radius.circular(24), + ), child: BackdropFilter( - filter: ColorFilter.mode(Colors.black.useOpacity(0.1), BlendMode.srcOver), + filter: ColorFilter.mode( + Colors.black.useOpacity(0.1), + BlendMode.srcOver, + ), child: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [Colors.white.useOpacity(0.1), Colors.white.useOpacity(0.05)], + colors: [ + Colors.white.useOpacity(0.1), + Colors.white.useOpacity(0.05), + ], + ), + border: Border( + bottom: BorderSide( + color: Colors.white.useOpacity(0.1), + width: 1, + ), ), - border: Border(bottom: BorderSide(color: Colors.white.useOpacity(0.1), width: 1)), ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), - child: Center(child: Text('Settings', style: context.textTheme.titleMedium)), + child: Center( + child: Text('Settings', style: context.textTheme.titleMedium), + ), ), ), ), diff --git a/miner-app/lib/features/settings/settings_screen.dart b/miner-app/lib/features/settings/settings_screen.dart index 3d40bf27..f77da330 100644 --- a/miner-app/lib/features/settings/settings_screen.dart +++ b/miner-app/lib/features/settings/settings_screen.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import 'package:quantus_miner/features/settings/settings_app_bar.dart'; +import 'package:quantus_miner/main.dart'; +import 'package:quantus_miner/src/config/miner_config.dart'; import 'package:quantus_miner/src/services/binary_manager.dart'; +import 'package:quantus_miner/src/services/miner_settings_service.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; class SettingsScreen extends StatefulWidget { @@ -15,32 +18,107 @@ class _SettingsScreenState extends State { BinaryVersion? _nodeUpdateInfo; bool _isLoading = true; + // Chain selection + final MinerSettingsService _settingsService = MinerSettingsService(); + String _selectedChainId = MinerConfig.defaultChainId; + @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { - _getBinaryInfo(); + _loadSettings(); }); } - Future _getBinaryInfo() async { - // Simulate a tiny delay for smooth UI transition if cached - // await Future.delayed(const Duration(milliseconds: 300)); - + Future _loadSettings() async { final [nodeUpdateInfo, minerUpdateInfo] = await Future.wait([ BinaryManager.getNodeBinaryVersion(), BinaryManager.getMinerBinaryVersion(), ]); + final chainId = await _settingsService.getChainId(); + if (mounted) { setState(() { _minerUpdateInfo = minerUpdateInfo; _nodeUpdateInfo = nodeUpdateInfo; + _selectedChainId = chainId; _isLoading = false; }); } } + Future _onChainChanged(String? newChainId) async { + if (newChainId == null || newChainId == _selectedChainId) return; + + // Check if mining is currently running + final orchestrator = GlobalMinerManager.getOrchestrator(); + final isMining = orchestrator?.isRunning ?? false; + + if (isMining) { + // Show warning dialog + final shouldChange = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: const Color(0xFF1C1C1C), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + title: const Text( + 'Stop Mining?', + style: TextStyle(color: Colors.white), + ), + content: const Text( + 'Changing the chain requires stopping mining first. ' + 'Do you want to stop mining and switch chains?', + style: TextStyle(color: Colors.white70), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text( + 'Cancel', + style: TextStyle(color: Colors.white.useOpacity(0.7)), + ), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom( + foregroundColor: const Color(0xFF00E676), + ), + child: const Text('Stop & Switch'), + ), + ], + ), + ); + + if (shouldChange != true) return; + + // Stop mining + await orchestrator?.stop(); + } + + // Save the new chain ID + await _settingsService.saveChainId(newChainId); + + if (mounted) { + setState(() { + _selectedChainId = newChainId; + }); + + // Show confirmation + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Switched to ${MinerConfig.getChainById(newChainId).displayName}', + ), + backgroundColor: const Color(0xFF00E676), + behavior: SnackBarBehavior.floating, + ), + ); + } + } + @override Widget build(BuildContext context) { // Define a theme-consistent accent color (e.g., a tech green or teal) @@ -70,7 +148,10 @@ class _SettingsScreenState extends State { SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 16.0), + padding: const EdgeInsets.symmetric( + horizontal: 20.0, + vertical: 16.0, + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -108,8 +189,22 @@ class _SettingsScreenState extends State { const SizedBox(height: 32), - // Example: You could add another section here later - // Text('ACCOUNT', style: ...), + // Network Section Header + Text( + 'NETWORK', + style: TextStyle( + color: Colors.white.useOpacity(0.5), + fontSize: 12, + letterSpacing: 1.5, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + + // Chain Selector + _buildChainSelector(accentColor), + + const SizedBox(height: 32), ], ), ), @@ -135,14 +230,23 @@ class _SettingsScreenState extends State { color: const Color(0xFF1C1C1C), // Slightly lighter than background borderRadius: BorderRadius.circular(16), border: Border.all(color: Colors.white.useOpacity(0.05), width: 1), - boxShadow: [BoxShadow(color: Colors.black.useOpacity(0.2), blurRadius: 10, offset: const Offset(0, 4))], + boxShadow: [ + BoxShadow( + color: Colors.black.useOpacity(0.2), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], ), child: Row( children: [ // Icon Container Container( padding: const EdgeInsets.all(10), - decoration: BoxDecoration(color: accentColor.useOpacity(0.1), borderRadius: BorderRadius.circular(12)), + decoration: BoxDecoration( + color: accentColor.useOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), child: Icon(icon, color: accentColor, size: 20), ), const SizedBox(width: 16), @@ -151,7 +255,11 @@ class _SettingsScreenState extends State { Expanded( child: Text( title, - style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w500), + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500, + ), ), ), @@ -160,7 +268,10 @@ class _SettingsScreenState extends State { SizedBox( width: 16, height: 16, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white.useOpacity(0.3)), + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white.useOpacity(0.3), + ), ) else Container( @@ -184,4 +295,105 @@ class _SettingsScreenState extends State { ), ); } + + Widget _buildChainSelector(Color accentColor) { + final selectedChain = MinerConfig.getChainById(_selectedChainId); + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFF1C1C1C), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.white.useOpacity(0.05), width: 1), + boxShadow: [ + BoxShadow( + color: Colors.black.useOpacity(0.2), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + children: [ + // Icon Container + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: accentColor.useOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(Icons.link_rounded, color: accentColor, size: 20), + ), + const SizedBox(width: 16), + + // Title and description + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Chain', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + selectedChain.description, + style: TextStyle( + color: Colors.white.useOpacity(0.5), + fontSize: 12, + ), + ), + ], + ), + ), + + // Dropdown + if (_isLoading) + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white.useOpacity(0.3), + ), + ) + else + Container( + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.white.useOpacity(0.1)), + ), + child: DropdownButton( + value: _selectedChainId, + dropdownColor: const Color(0xFF1C1C1C), + underline: const SizedBox(), + icon: Icon( + Icons.arrow_drop_down, + color: Colors.white.useOpacity(0.7), + ), + style: TextStyle( + color: Colors.white.useOpacity(0.9), + fontFamily: 'Courier', + fontWeight: FontWeight.bold, + fontSize: 13, + ), + items: MinerConfig.availableChains.map((chain) { + return DropdownMenuItem( + value: chain.id, + child: Text(chain.displayName), + ); + }).toList(), + onChanged: _onChainChanged, + ), + ), + ], + ), + ); + } } diff --git a/miner-app/lib/features/setup/node_identity_setup_screen.dart b/miner-app/lib/features/setup/node_identity_setup_screen.dart index c58f6604..55bc4d29 100644 --- a/miner-app/lib/features/setup/node_identity_setup_screen.dart +++ b/miner-app/lib/features/setup/node_identity_setup_screen.dart @@ -8,7 +8,8 @@ class NodeIdentitySetupScreen extends StatefulWidget { const NodeIdentitySetupScreen({super.key}); @override - State createState() => _NodeIdentitySetupScreenState(); + State createState() => + _NodeIdentitySetupScreenState(); } class _NodeIdentitySetupScreenState extends State { @@ -88,7 +89,10 @@ class _NodeIdentitySetupScreenState extends State { children: [ const Icon(Icons.check_circle, color: Colors.green, size: 80), const SizedBox(height: 16), - const Text('Node Identity Set!', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), + const Text( + 'Node Identity Set!', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), const SizedBox(height: 24), ElevatedButton( onPressed: () { @@ -106,7 +110,10 @@ class _NodeIdentitySetupScreenState extends State { children: [ SvgPicture.asset('assets/logo/logo.svg', width: 80, height: 80), const SizedBox(height: 16), - const Text('Node Identity not set.', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), + const Text( + 'Node Identity not set.', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), const SizedBox(height: 8), const Text( 'You need to set a node identity to continue.', diff --git a/miner-app/lib/features/setup/node_setup_screen.dart b/miner-app/lib/features/setup/node_setup_screen.dart index 6e86da7c..a5665651 100644 --- a/miner-app/lib/features/setup/node_setup_screen.dart +++ b/miner-app/lib/features/setup/node_setup_screen.dart @@ -36,7 +36,8 @@ class _NodeSetupScreenState extends State { final String nodeBinaryPath = await BinaryManager.getNodeBinaryFilePath(); final bool nodeInstalled = await File(nodeBinaryPath).exists(); - final String minerBinaryPath = await BinaryManager.getExternalMinerBinaryFilePath(); + final String minerBinaryPath = + await BinaryManager.getExternalMinerBinaryFilePath(); final bool minerInstalled = await File(minerBinaryPath).exists(); setState(() { @@ -78,12 +79,15 @@ class _NodeSetupScreenState extends State { if (mounted) { setState(() { if (progress.totalBytes > 0) { - _downloadProgress = progress.downloadedBytes / progress.totalBytes; + _downloadProgress = + progress.downloadedBytes / progress.totalBytes; _downloadProgressText = "Node: ${(progress.downloadedBytes / (1024 * 1024)).toStringAsFixed(2)} MB / ${(progress.totalBytes / (1024 * 1024)).toStringAsFixed(2)} MB"; } else { _downloadProgress = progress.downloadedBytes > 0 ? 1.0 : 0.0; - _downloadProgressText = progress.downloadedBytes > 0 ? "Node Downloaded" : "Downloading Node..."; + _downloadProgressText = progress.downloadedBytes > 0 + ? "Node Downloaded" + : "Downloading Node..."; } }); } @@ -110,12 +114,15 @@ class _NodeSetupScreenState extends State { if (mounted) { setState(() { if (progress.totalBytes > 0) { - _downloadProgress = progress.downloadedBytes / progress.totalBytes; + _downloadProgress = + progress.downloadedBytes / progress.totalBytes; _downloadProgressText = "Miner: ${(progress.downloadedBytes / (1024 * 1024)).toStringAsFixed(2)} MB / ${(progress.totalBytes / (1024 * 1024)).toStringAsFixed(2)} MB"; } else { _downloadProgress = progress.downloadedBytes > 0 ? 1.0 : 0.0; - _downloadProgressText = progress.downloadedBytes > 0 ? "Miner Downloaded" : "Downloading Miner..."; + _downloadProgressText = progress.downloadedBytes > 0 + ? "Miner Downloaded" + : "Downloading Miner..."; } }); } @@ -147,14 +154,15 @@ class _NodeSetupScreenState extends State { }); } if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Error installing binaries: ${e.toString()}'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error installing binaries: ${e.toString()}')), + ); } } } - bool get _allBinariesInstalled => _isNodeInstalled && _isExternalMinerInstalled; + bool get _allBinariesInstalled => + _isNodeInstalled && _isExternalMinerInstalled; @override Widget build(BuildContext context) { @@ -164,13 +172,22 @@ class _NodeSetupScreenState extends State { bodyContent = Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text('Installing Mining Software...', style: Theme.of(context).textTheme.headlineSmall), + Text( + 'Installing Mining Software...', + style: Theme.of(context).textTheme.headlineSmall, + ), const SizedBox(height: 8), - Text(_currentDownloadingBinary, style: Theme.of(context).textTheme.titleMedium), + Text( + _currentDownloadingBinary, + style: Theme.of(context).textTheme.titleMedium, + ), const SizedBox(height: 20), Padding( padding: const EdgeInsets.symmetric(horizontal: 40.0), - child: LinearProgressIndicator(value: _downloadProgress, minHeight: 10), + child: LinearProgressIndicator( + value: _downloadProgress, + minHeight: 10, + ), ), const SizedBox(height: 10), Text(_downloadProgressText), @@ -196,7 +213,10 @@ class _NodeSetupScreenState extends State { children: [ const Icon(Icons.check_circle, color: Colors.green, size: 80), const SizedBox(height: 16), - const Text('Mining Software Installed!', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), + const Text( + 'Mining Software Installed!', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), const SizedBox(height: 8), Column( children: [ @@ -237,7 +257,10 @@ class _NodeSetupScreenState extends State { children: [ SvgPicture.asset('assets/logo/logo.svg', width: 80, height: 80), const SizedBox(height: 16), - const Text('Mining software not found.', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), + const Text( + 'Mining software not found.', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), const SizedBox(height: 8), const Text( 'You need to install the node and external miner to continue.', @@ -279,7 +302,9 @@ class _NodeSetupScreenState extends State { ElevatedButton.icon( onPressed: _installBinaries, icon: const Icon(Icons.download), - label: Text(_allBinariesInstalled ? 'All Installed' : 'Install Mining Software'), + label: Text( + _allBinariesInstalled ? 'All Installed' : 'Install Mining Software', + ), style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), textStyle: const TextStyle(fontSize: 18), diff --git a/miner-app/lib/features/setup/rewards_address_setup_screen.dart b/miner-app/lib/features/setup/rewards_address_setup_screen.dart index 5c60d10f..1979fc29 100644 --- a/miner-app/lib/features/setup/rewards_address_setup_screen.dart +++ b/miner-app/lib/features/setup/rewards_address_setup_screen.dart @@ -13,7 +13,8 @@ class RewardsAddressSetupScreen extends StatefulWidget { const RewardsAddressSetupScreen({super.key}); @override - State createState() => _RewardsAddressSetupScreenState(); + State createState() => + _RewardsAddressSetupScreenState(); } class _RewardsAddressSetupScreenState extends State { @@ -67,7 +68,10 @@ class _RewardsAddressSetupScreenState extends State { Future _saveRewardsAddress() async { final address = _addressController.text.trim(); if (address.isEmpty) { - context.showErrorSnackbar(title: 'Error', message: 'Please enter a valid address'); + context.showErrorSnackbar( + title: 'Error', + message: 'Please enter a valid address', + ); return; } @@ -83,14 +87,19 @@ class _RewardsAddressSetupScreenState extends State { print('Rewards address saved: $address'); if (mounted) { - context.showSuccessBar(content: Text('Rewards address saved successfully!')); + context.showSuccessBar( + content: const Text('Rewards address saved successfully!'), + ); // Navigate to the main mining screen context.go('/miner_dashboard'); } } catch (e) { print('Error saving rewards address: $e'); if (mounted) { - context.showErrorSnackbar(title: 'Error', message: 'Error saving address: $e'); + context.showErrorSnackbar( + title: 'Error', + message: 'Error saving address: $e', + ); } } finally { if (mounted) { @@ -119,7 +128,11 @@ class _RewardsAddressSetupScreenState extends State { color: Colors.black87, borderRadius: BorderRadius.circular(16), boxShadow: [ - BoxShadow(color: Colors.black.useOpacity(0.5), blurRadius: 20, offset: const Offset(0, 10)), + BoxShadow( + color: Colors.black.useOpacity(0.5), + blurRadius: 20, + offset: const Offset(0, 10), + ), ], ), child: Column( @@ -135,7 +148,11 @@ class _RewardsAddressSetupScreenState extends State { top: 0, child: GestureDetector( onTap: () => Navigator.of(context).pop(), - child: const Icon(Icons.close, color: Colors.white, size: 24), + child: const Icon( + Icons.close, + color: Colors.white, + size: 24, + ), ), ), ], @@ -158,7 +175,11 @@ class _RewardsAddressSetupScreenState extends State { const Text( 'Scan with your mobile phone\nto set up your wallet', textAlign: TextAlign.center, - style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w500), + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500, + ), ), const SizedBox(height: 24), OutlinedButton( @@ -166,7 +187,10 @@ class _RewardsAddressSetupScreenState extends State { style: OutlinedButton.styleFrom( foregroundColor: Colors.white, side: const BorderSide(color: Colors.white), - padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16), + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 16, + ), ), child: const Text('Close'), ), @@ -194,11 +218,18 @@ class _RewardsAddressSetupScreenState extends State { mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - SvgPicture.asset('assets/logo/logo.svg', width: 80, height: 80), + SvgPicture.asset( + 'assets/logo/logo.svg', + width: 80, + height: 80, + ), const SizedBox(height: 24), const Text( 'Add Rewards Account', - style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), textAlign: TextAlign.center, ), const SizedBox(height: 8), @@ -215,7 +246,9 @@ class _RewardsAddressSetupScreenState extends State { enableInteractiveSelection: true, onSubmitted: (_) => _saveRewardsAddress(), contextMenuBuilder: (context, editableTextState) { - return AdaptiveTextSelectionToolbar.editableText(editableTextState: editableTextState); + return AdaptiveTextSelectionToolbar.editableText( + editableTextState: editableTextState, + ); }, decoration: InputDecoration( labelText: 'Rewards Wallet Address', @@ -236,7 +269,9 @@ class _RewardsAddressSetupScreenState extends State { IconButton( icon: const Icon(Icons.paste), onPressed: () async { - final data = await Clipboard.getData(Clipboard.kTextPlain); + final data = await Clipboard.getData( + Clipboard.kTextPlain, + ); if (data?.text != null) { _addressController.text = data!.text!; } @@ -264,7 +299,10 @@ class _RewardsAddressSetupScreenState extends State { const Text( "Don't have an account?", textAlign: TextAlign.center, - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), ), const SizedBox(height: 8), const Text( @@ -277,7 +315,9 @@ class _RewardsAddressSetupScreenState extends State { onPressed: _showQrOverlay, icon: const Icon(Icons.qr_code), label: const Text('Scan QR code to set up wallet'), - style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12)), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + ), ), ], ), diff --git a/miner-app/lib/main.dart b/miner-app/lib/main.dart index ae01d2be..7f9b5fdc 100644 --- a/miner-app/lib/main.dart +++ b/miner-app/lib/main.dart @@ -8,86 +8,104 @@ import 'features/setup/node_identity_setup_screen.dart'; import 'features/setup/rewards_address_setup_screen.dart'; import 'features/miner/miner_dashboard_screen.dart'; import 'src/services/binary_manager.dart'; -import 'src/services/miner_process.dart'; +import 'src/services/mining_orchestrator.dart'; +import 'src/services/process_cleanup_service.dart'; +import 'src/utils/app_logger.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; -/// Global class to manage miner process lifecycle +final _log = log.withTag('App'); + +/// Global class to manage mining orchestrator lifecycle. +/// +/// This is used for cleanup during app exit/detach events. class GlobalMinerManager { - static MinerProcess? _globalMinerProcess; + static MiningOrchestrator? _orchestrator; - static void setMinerProcess(MinerProcess? process) { - _globalMinerProcess = process; - print('GlobalMinerManager: Set miner process: ${process != null}'); + /// Register the active orchestrator for lifecycle management. + static void setOrchestrator(MiningOrchestrator? orchestrator) { + _orchestrator = orchestrator; + _log.d('Orchestrator registered: ${orchestrator != null}'); } - static MinerProcess? getMinerProcess() { - return _globalMinerProcess; + /// Get the current orchestrator, if any. + static MiningOrchestrator? getOrchestrator() { + return _orchestrator; } - static Future cleanup() async { - print('GlobalMinerManager: Starting cleanup...'); - if (_globalMinerProcess != null) { + /// Synchronous force stop for app detach scenarios. + /// + /// This is called from _onAppDetach which cannot be async. + /// It fires off process kills without waiting for completion. + static void forceStopAll() { + _log.i('Force stopping all processes (sync)...'); + if (_orchestrator != null) { try { - print('GlobalMinerManager: Force stopping global miner process'); - _globalMinerProcess!.forceStop(); - _globalMinerProcess = null; + _orchestrator!.forceStop(); + _orchestrator = null; } catch (e) { - print('GlobalMinerManager: Error stopping miner process: $e'); + _log.e('Error force stopping orchestrator', error: e); } } - // Kill any remaining quantus processes - await _killQuantusProcesses(); + // Fire and forget - kill any remaining quantus processes + ProcessCleanupService.killAllQuantusProcesses(); } - static Future _killQuantusProcesses() async { - try { - print('GlobalMinerManager: Killing quantus processes by name...'); - - // Kill all quantus processes - await Process.run('pkill', ['-9', '-f', 'quantus-node']); - await Process.run('pkill', ['-9', '-f', 'quantus-miner']); - - print('GlobalMinerManager: Cleanup commands executed'); - } catch (e) { - print('GlobalMinerManager: Error killing processes by name: $e'); + /// Cleanup all mining processes. + /// + /// Called during app exit (async context). + static Future cleanup() async { + _log.i('Starting global cleanup...'); + if (_orchestrator != null) { + try { + _orchestrator!.forceStop(); + _orchestrator = null; + } catch (e) { + _log.e('Error stopping orchestrator', error: e); + } } + + // Kill any remaining quantus processes using the cleanup service + await ProcessCleanupService.killAllQuantusProcesses(); } } -Future initialRedirect(BuildContext context, GoRouterState state) async { +Future initialRedirect( + BuildContext context, + GoRouterState state, +) async { final currentRoute = state.uri.toString(); - print('initialRedirect'); - // Check 1: Node Installed bool isNodeInstalled = false; try { isNodeInstalled = await BinaryManager.hasBinary(); - print('isNodeInstalled: $isNodeInstalled'); } catch (e) { - print('Error checking node installation status: $e'); + _log.e('Error checking node installation', error: e); isNodeInstalled = false; } if (!isNodeInstalled) { - print('node not installed, going to node setup'); + _log.d('Node not installed, redirecting to setup'); return (currentRoute == '/node_setup') ? null : '/node_setup'; } // Check 2: Node Identity Set bool isIdentitySet = false; try { - final identityPath = '${await BinaryManager.getQuantusHomeDirectoryPath()}/node_key.p2p'; + final identityPath = + '${await BinaryManager.getQuantusHomeDirectoryPath()}/node_key.p2p'; isIdentitySet = await File(identityPath).exists(); } catch (e) { - print('Error checking node identity status: $e'); + _log.e('Error checking node identity', error: e); isIdentitySet = false; } if (!isIdentitySet) { - return (currentRoute == '/node_identity_setup') ? null : '/node_identity_setup'; + return (currentRoute == '/node_identity_setup') + ? null + : '/node_identity_setup'; } // Check 3: Rewards Address Set @@ -97,12 +115,14 @@ Future initialRedirect(BuildContext context, GoRouterState state) async final rewardsFile = File('$quantusHome/rewards-address.txt'); isRewardsAddressSet = await rewardsFile.exists(); } catch (e) { - print('Error checking rewards address status: $e'); + _log.e('Error checking rewards address', error: e); isRewardsAddressSet = false; } if (!isRewardsAddressSet) { - return (currentRoute == '/rewards_address_setup') ? null : '/rewards_address_setup'; + return (currentRoute == '/rewards_address_setup') + ? null + : '/rewards_address_setup'; } // If all setup steps are complete, go to the miner dashboard @@ -117,12 +137,25 @@ final _router = GoRouter( path: '/', // Builder is not strictly necessary if initialLocation and redirect handle it, // but can be a fallback or initial loading screen. - builder: (context, state) => const Scaffold(body: Center(child: CircularProgressIndicator())), + builder: (context, state) => + const Scaffold(body: Center(child: CircularProgressIndicator())), + ), + GoRoute( + path: '/node_setup', + builder: (context, state) => const NodeSetupScreen(), + ), + GoRoute( + path: '/node_identity_setup', + builder: (context, state) => const NodeIdentitySetupScreen(), + ), + GoRoute( + path: '/rewards_address_setup', + builder: (context, state) => const RewardsAddressSetupScreen(), + ), + GoRoute( + path: '/miner_dashboard', + builder: (context, state) => const MinerDashboardScreen(), ), - GoRoute(path: '/node_setup', builder: (context, state) => const NodeSetupScreen()), - GoRoute(path: '/node_identity_setup', builder: (context, state) => const NodeIdentitySetupScreen()), - GoRoute(path: '/rewards_address_setup', builder: (context, state) => const RewardsAddressSetupScreen()), - GoRoute(path: '/miner_dashboard', builder: (context, state) => const MinerDashboardScreen()), ], ); @@ -133,10 +166,9 @@ Future main() async { try { await QuantusSdk.init(); - print('SubstrateService and QuantusSdk initialized successfully.'); + _log.i('SDK initialized'); } catch (e) { - print('Error initializing SDK: $e'); - // Depending on the app, you might want to show an error UI or prevent app startup + _log.e('Error initializing SDK', error: e); } runApp(const MinerApp()); } @@ -170,34 +202,34 @@ class _MinerAppState extends State { } void _onAppDetach() { - print('App lifecycle: App detached, forcing cleanup...'); - GlobalMinerManager.cleanup(); + _log.i('App detached, cleaning up...'); + // Use synchronous force stop since _onAppDetach cannot be async + GlobalMinerManager.forceStopAll(); } Future _onExitRequested() async { - print('App lifecycle: Exit requested, cleaning up processes...'); + _log.i('Exit requested, cleaning up...'); try { await GlobalMinerManager.cleanup(); - print('App lifecycle: Cleanup completed, allowing exit'); return AppExitResponse.exit; } catch (e) { - print('App lifecycle: Error during cleanup: $e'); + _log.e('Error during exit cleanup', error: e); // Still allow exit even if cleanup fails return AppExitResponse.exit; } } void _onStateChanged(AppLifecycleState state) { - print('App lifecycle state changed to: $state'); - - if (state == AppLifecycleState.paused || state == AppLifecycleState.detached) { - print('App lifecycle: App backgrounded/detached, cleaning up...'); - GlobalMinerManager.cleanup(); - } + _log.d('Lifecycle state: $state'); + // Note: We intentionally do NOT cleanup on pause/background + // Mining should continue when the app is backgrounded } @override - Widget build(BuildContext context) => - MaterialApp.router(title: 'Quantus Miner', theme: ThemeData.dark(useMaterial3: true), routerConfig: _router); + Widget build(BuildContext context) => MaterialApp.router( + title: 'Quantus Miner', + theme: ThemeData.dark(useMaterial3: true), + routerConfig: _router, + ); } diff --git a/miner-app/lib/src/config/miner_config.dart b/miner-app/lib/src/config/miner_config.dart new file mode 100644 index 00000000..f10d5529 --- /dev/null +++ b/miner-app/lib/src/config/miner_config.dart @@ -0,0 +1,193 @@ +/// Centralized configuration for the miner application. +/// +/// All ports, timeouts, URLs, and other constants should be defined here +/// rather than scattered throughout the codebase. +class MinerConfig { + MinerConfig._(); + + // ============================================================ + // Network Ports + // ============================================================ + + /// QUIC port for miner-to-node communication + static const int defaultQuicPort = 9833; + + /// Prometheus metrics port for the external miner + static const int defaultMinerMetricsPort = 9900; + + /// JSON-RPC port for the node + static const int defaultNodeRpcPort = 9933; + + /// Prometheus metrics port for the node + static const int defaultNodePrometheusPort = 9616; + + /// P2P port for node networking + static const int defaultNodeP2pPort = 30333; + + // ============================================================ + // Timeouts & Retry Configuration + // ============================================================ + + /// Time to wait for graceful process shutdown before force killing + static const Duration gracefulShutdownTimeout = Duration(seconds: 2); + + /// Initial delay between RPC connection retries + static const Duration rpcInitialRetryDelay = Duration(seconds: 2); + + /// Maximum delay between RPC connection retries (with exponential backoff) + static const Duration rpcMaxRetryDelay = Duration(seconds: 10); + + /// Maximum number of RPC connection attempts before giving up + static const int maxRpcRetries = 30; + + /// Number of consecutive metrics failures before resetting hashrate to zero + static const int maxConsecutiveMetricsFailures = 5; + + /// Delay after killing a process before checking if port is free + static const Duration portCleanupDelay = Duration(seconds: 1); + + /// Delay after process cleanup before continuing + static const Duration processCleanupDelay = Duration(seconds: 2); + + /// Timeout for force kill operations + static const Duration forceKillTimeout = Duration(seconds: 5); + + /// Delay for process verification after kill + static const Duration processVerificationDelay = Duration(milliseconds: 500); + + // ============================================================ + // Polling Intervals + // ============================================================ + + /// How often to poll external miner metrics endpoint + static const Duration metricsPollingInterval = Duration(seconds: 1); + + /// How often to poll node Prometheus metrics (for target block) + static const Duration prometheusPollingInterval = Duration(seconds: 3); + + /// How often to check for binary updates + static const Duration binaryUpdatePollingInterval = Duration(minutes: 30); + + /// How often to poll chain RPC for peer count and block info + static const Duration chainRpcPollingInterval = Duration(seconds: 1); + + /// How often to poll wallet balance (backup timer) + static const Duration balancePollingInterval = Duration(seconds: 30); + + // ============================================================ + // Hardware Detection + // ============================================================ + + /// Maximum number of GPU devices to probe for during detection + static const int maxGpuProbeCount = 8; + + // ============================================================ + // URLs & Endpoints + // ============================================================ + + /// Returns the miner metrics URL for a given port + static String minerMetricsUrl(int port) => 'http://127.0.0.1:$port/metrics'; + + /// Returns the node RPC URL for a given port + static String nodeRpcUrl(int port) => 'http://127.0.0.1:$port'; + + /// Returns the node Prometheus metrics URL for a given port + static String nodePrometheusUrl(int port) => 'http://127.0.0.1:$port/metrics'; + + /// Default localhost address for connections + static const String localhost = '127.0.0.1'; + + // ============================================================ + // Chain Configuration + // ============================================================ + + /// Available chain IDs + static const List availableChains = [ + ChainConfig( + id: 'dev', + displayName: 'Development', + description: 'Local development chain', + rpcUrl: 'http://127.0.0.1:9933', + isDefault: true, + ), + ChainConfig( + id: 'dirac', + displayName: 'Dirac', + description: 'Dirac testnet', + rpcUrl: 'https://a1-dirac.quantus.cat', + isDefault: false, + ), + ]; + + /// Get chain config by ID, returns dev chain if not found + static ChainConfig getChainById(String id) { + return availableChains.firstWhere( + (chain) => chain.id == id, + orElse: () => availableChains.first, + ); + } + + /// The default chain ID + static String get defaultChainId => + availableChains.firstWhere((c) => c.isDefault).id; + + // ============================================================ + // Process Names (for cleanup) + // ============================================================ + + /// Node binary name (without extension) + static const String nodeBinaryName = 'quantus-node'; + + /// Miner binary name (without extension) + static const String minerBinaryName = 'quantus-miner'; + + /// Node binary name with Windows extension + static String get nodeBinaryNameWindows => '$nodeBinaryName.exe'; + + /// Miner binary name with Windows extension + static String get minerBinaryNameWindows => '$minerBinaryName.exe'; + + // ============================================================ + // Logging + // ============================================================ + + /// Maximum number of log lines to keep in memory + static const int maxLogLines = 200; + + /// Initial lines to print before filtering kicks in + static const int initialLinesToPrint = 50; + + // ============================================================ + // Port Range for Finding Alternatives + // ============================================================ + + /// Number of ports to try when finding an alternative + static const int portSearchRange = 10; +} + +/// Configuration for a blockchain network. +/// +/// Named ChainConfig to avoid conflict with ChainInfo in chain_rpc_client.dart +class ChainConfig { + final String id; + final String displayName; + final String description; + final String rpcUrl; + final bool isDefault; + + const ChainConfig({ + required this.id, + required this.displayName, + required this.description, + required this.rpcUrl, + required this.isDefault, + }); + + /// Whether this chain uses the local node RPC + bool get isLocalNode => + rpcUrl.contains('127.0.0.1') || rpcUrl.contains('localhost'); + + @override + String toString() => + 'ChainConfig(id: $id, displayName: $displayName, rpcUrl: $rpcUrl)'; +} diff --git a/miner-app/lib/src/models/miner_error.dart b/miner-app/lib/src/models/miner_error.dart new file mode 100644 index 00000000..ba902485 --- /dev/null +++ b/miner-app/lib/src/models/miner_error.dart @@ -0,0 +1,92 @@ +/// Types of errors that can occur during mining. +enum MinerErrorType { + /// The miner process crashed unexpectedly. + minerCrashed, + + /// The node process crashed unexpectedly. + nodeCrashed, + + /// Failed to start the miner process. + minerStartupFailed, + + /// Failed to start the node process. + nodeStartupFailed, + + /// Lost connection to the miner metrics endpoint. + metricsConnectionLost, + + /// Lost connection to the node RPC endpoint. + rpcConnectionLost, + + /// Generic/unknown error. + unknown, +} + +/// Represents an error that occurred during mining operations. +class MinerError { + /// The type of error. + final MinerErrorType type; + + /// Human-readable error message. + final String message; + + /// Process exit code, if applicable. + final int? exitCode; + + /// The underlying exception, if any. + final Object? exception; + + /// Stack trace, if available. + final StackTrace? stackTrace; + + /// When the error occurred. + final DateTime timestamp; + + MinerError({ + required this.type, + required this.message, + this.exitCode, + this.exception, + this.stackTrace, + DateTime? timestamp, + }) : timestamp = timestamp ?? DateTime.now(); + + /// Create a miner crash error. + factory MinerError.minerCrashed(int exitCode) => MinerError( + type: MinerErrorType.minerCrashed, + message: 'Miner process crashed unexpectedly (exit code: $exitCode)', + exitCode: exitCode, + ); + + /// Create a node crash error. + factory MinerError.nodeCrashed(int exitCode) => MinerError( + type: MinerErrorType.nodeCrashed, + message: 'Node process crashed unexpectedly (exit code: $exitCode)', + exitCode: exitCode, + ); + + /// Create a miner startup failure error. + factory MinerError.minerStartupFailed( + Object error, [ + StackTrace? stackTrace, + ]) => MinerError( + type: MinerErrorType.minerStartupFailed, + message: 'Failed to start miner: $error', + exception: error, + stackTrace: stackTrace, + ); + + /// Create a node startup failure error. + factory MinerError.nodeStartupFailed( + Object error, [ + StackTrace? stackTrace, + ]) => MinerError( + type: MinerErrorType.nodeStartupFailed, + message: 'Failed to start node: $error', + exception: error, + stackTrace: stackTrace, + ); + + @override + String toString() => 'MinerError($type): $message'; +} diff --git a/miner-app/lib/src/services/base_process_manager.dart b/miner-app/lib/src/services/base_process_manager.dart new file mode 100644 index 00000000..6142e3ec --- /dev/null +++ b/miner-app/lib/src/services/base_process_manager.dart @@ -0,0 +1,171 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart' show protected; +import 'package:quantus_miner/src/config/miner_config.dart'; +import 'package:quantus_miner/src/models/miner_error.dart'; +import 'package:quantus_miner/src/services/log_stream_processor.dart'; +import 'package:quantus_miner/src/services/process_cleanup_service.dart'; +import 'package:quantus_miner/src/utils/app_logger.dart'; + +/// Abstract base class for process managers. +/// +/// Provides common functionality for managing external processes like +/// the node and miner processes. +abstract class BaseProcessManager { + Process? _process; + late LogStreamProcessor _logProcessor; + final _errorController = StreamController.broadcast(); + + bool _intentionalStop = false; + + /// Tag for logging - subclasses should override + TaggedLoggerWrapper get log; + + /// Name of this process type (for logging) + String get processName; + + /// Stream of log entries from the process. + Stream get logs => _logProcessor.logs; + + /// Stream of errors (crashes, startup failures). + Stream get errors => _errorController.stream; + + /// The process ID, or null if not running. + int? get pid => _process?.pid; + + /// Whether the process is currently running. + bool get isRunning => _process != null; + + /// Access to the error controller for subclasses + @protected + StreamController get errorController => _errorController; + + /// Set the intentional stop flag (for subclasses) + @protected + set intentionalStop(bool value) => _intentionalStop = value; + + /// Clear the process reference (for subclasses) + @protected + void clearProcess() => _process = null; + + /// Initialize the log processor for a source + void initLogProcessor(String sourceName, {SyncStateProvider? getSyncState}) { + _logProcessor = LogStreamProcessor( + sourceName: sourceName, + getSyncState: getSyncState, + ); + } + + /// Attach process streams to log processor + void attachProcess(Process process) { + _process = process; + _logProcessor.attach(process); + } + + /// Create an error for startup failure - subclasses should override + MinerError createStartupError(dynamic error, [StackTrace? stackTrace]); + + /// Create an error for crash - subclasses should override + MinerError createCrashError(int exitCode); + + /// Stop the process gracefully. + /// + /// Returns a Future that completes when the process has stopped. + Future stop() async { + if (_process == null) { + return; + } + + _intentionalStop = true; + final processPid = _process!.pid; + log.i('Stopping $processName (PID: $processPid)...'); + + // Try graceful termination first + _process!.kill(ProcessSignal.sigterm); + + // Wait for graceful shutdown + final exited = await _waitForExit(MinerConfig.gracefulShutdownTimeout); + + if (!exited) { + // Force kill if still running + log.d('$processName still running, force killing...'); + await _forceKill(); + } + + _cleanup(); + log.i('$processName stopped'); + } + + /// Force stop the process immediately. + void forceStop() { + if (_process == null) { + return; + } + + _intentionalStop = true; + final processPid = _process!.pid; + log.i('Force stopping $processName (PID: $processPid)...'); + + try { + _process!.kill(ProcessSignal.sigkill); + } catch (e) { + log.e('Error force killing $processName', error: e); + } + + // Also use system cleanup as backup + ProcessCleanupService.forceKillProcess(processPid, processName); + + _cleanup(); + } + + /// Handle process exit. + void handleExit(int exitCode) { + if (_intentionalStop) { + log.d('$processName exited (code: $exitCode) - intentional stop'); + } else { + log.w('$processName crashed (exit code: $exitCode)'); + _errorController.add(createCrashError(exitCode)); + } + _cleanup(); + } + + /// Dispose of all resources. + void dispose() { + forceStop(); + _logProcessor.dispose(); + if (!_errorController.isClosed) { + _errorController.close(); + } + } + + Future _waitForExit(Duration timeout) async { + if (_process == null) return true; + + try { + await _process!.exitCode.timeout(timeout); + return true; + } on TimeoutException { + return false; + } + } + + Future _forceKill() async { + if (_process == null) return; + + try { + _process!.kill(ProcessSignal.sigkill); + await _process!.exitCode.timeout( + MinerConfig.processVerificationDelay, + onTimeout: () => -1, + ); + } catch (e) { + log.e('Error during force kill', error: e); + } + } + + void _cleanup() { + _logProcessor.detach(); + _process = null; + } +} diff --git a/miner-app/lib/src/services/binary_manager.dart b/miner-app/lib/src/services/binary_manager.dart index 010b62c5..474ca915 100644 --- a/miner-app/lib/src/services/binary_manager.dart +++ b/miner-app/lib/src/services/binary_manager.dart @@ -4,6 +4,9 @@ import 'dart:io'; import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; +import 'package:quantus_miner/src/utils/app_logger.dart'; + +final _log = log.withTag('BinaryManager'); class DownloadProgress { final int downloadedBytes; @@ -18,10 +21,15 @@ class BinaryVersion { BinaryVersion(this.version, this.checkedAt); - Map toJson() => {'version': version, 'checkedAt': checkedAt.toIso8601String()}; + Map toJson() => { + 'version': version, + 'checkedAt': checkedAt.toIso8601String(), + }; - factory BinaryVersion.fromJson(Map json) => - BinaryVersion(json['version'] as String, DateTime.parse(json['checkedAt'] as String)); + factory BinaryVersion.fromJson(Map json) => BinaryVersion( + json['version'] as String, + DateTime.parse(json['checkedAt'] as String), + ); } class BinaryUpdateInfo { @@ -30,7 +38,12 @@ class BinaryUpdateInfo { final String? latestVersion; final String? downloadUrl; - BinaryUpdateInfo({required this.updateAvailable, this.currentVersion, this.latestVersion, this.downloadUrl}); + BinaryUpdateInfo({ + required this.updateAvailable, + this.currentVersion, + this.latestVersion, + this.downloadUrl, + }); } class BinaryManager { @@ -89,7 +102,7 @@ class BinaryManager { final json = jsonDecode(content) as Map; return BinaryVersion.fromJson(json); } catch (e) { - print('Error reading node version file: $e'); + _log.d('Error reading node version file: $e'); return null; } } @@ -107,7 +120,7 @@ class BinaryManager { final json = jsonDecode(content) as Map; return BinaryVersion.fromJson(json); } catch (e) { - print('Error reading miner version file: $e'); + _log.d('Error reading miner version file: $e'); return null; } } @@ -127,7 +140,11 @@ class BinaryManager { } static Future getLatestNodeVersion() async { - final rel = await http.get(Uri.parse('https://api.github.com/repos/$_repoOwner/$_repoName/releases/latest')); + final rel = await http.get( + Uri.parse( + 'https://api.github.com/repos/$_repoOwner/$_repoName/releases/latest', + ), + ); if (rel.statusCode != 200) { throw Exception('Failed to fetch latest node version: ${rel.statusCode}'); @@ -137,10 +154,16 @@ class BinaryManager { } static Future getLatestMinerVersion() async { - final rel = await http.get(Uri.parse('https://api.github.com/repos/$_repoOwner/$_minerRepoName/releases/latest')); + final rel = await http.get( + Uri.parse( + 'https://api.github.com/repos/$_repoOwner/$_minerRepoName/releases/latest', + ), + ); if (rel.statusCode != 200) { - throw Exception('Failed to fetch latest miner version: ${rel.statusCode}'); + throw Exception( + 'Failed to fetch latest miner version: ${rel.statusCode}', + ); } return jsonDecode(rel.body)['tag_name'] as String; @@ -159,16 +182,21 @@ class BinaryManager { ); } - final updateAvailable = _isNewerVersion(currentVersion.version, latestVersion); + final updateAvailable = _isNewerVersion( + currentVersion.version, + latestVersion, + ); return BinaryUpdateInfo( updateAvailable: updateAvailable, currentVersion: currentVersion.version, latestVersion: latestVersion, - downloadUrl: updateAvailable ? _buildNodeDownloadUrl(latestVersion) : null, + downloadUrl: updateAvailable + ? _buildNodeDownloadUrl(latestVersion) + : null, ); } catch (e) { - print('Error checking node update: $e'); + _log.w('Error checking node update', error: e); return BinaryUpdateInfo(updateAvailable: false); } } @@ -186,16 +214,21 @@ class BinaryManager { ); } - final updateAvailable = _isNewerVersion(currentVersion.version, latestVersion); + final updateAvailable = _isNewerVersion( + currentVersion.version, + latestVersion, + ); return BinaryUpdateInfo( updateAvailable: updateAvailable, currentVersion: currentVersion.version, latestVersion: latestVersion, - downloadUrl: updateAvailable ? _buildMinerDownloadUrl(latestVersion) : null, + downloadUrl: updateAvailable + ? _buildMinerDownloadUrl(latestVersion) + : null, ); } catch (e) { - print('Error checking miner update: $e'); + _log.w('Error checking miner update', error: e); return BinaryUpdateInfo(updateAvailable: false); } } @@ -225,7 +258,8 @@ class BinaryManager { // Force x86_64 on Windows to support x64 emulation on ARM devices // unless we specifically start releasing native ARM64 Windows binaries arch = 'x86_64'; - } else if (Platform.version.contains('arm64') || Platform.version.contains('aarch64')) { + } else if (Platform.version.contains('arm64') || + Platform.version.contains('aarch64')) { arch = 'aarch64'; } else { arch = 'x86_64'; @@ -240,7 +274,9 @@ class BinaryManager { static bool _isNewerVersion(String current, String latest) { // Remove 'v' prefix if present - final currentClean = current.startsWith('v') ? current.substring(1) : current; + final currentClean = current.startsWith('v') + ? current.substring(1) + : current; final latestClean = latest.startsWith('v') ? latest.substring(1) : latest; final currentParts = currentClean.split('.').map(int.tryParse).toList(); @@ -274,8 +310,10 @@ class BinaryManager { return await _downloadNodeBinary(onProgress: onProgress); } - static Future updateNodeBinary({void Function(DownloadProgress progress)? onProgress}) async { - print('Updating node binary to latest version...'); + static Future updateNodeBinary({ + void Function(DownloadProgress progress)? onProgress, + }) async { + _log.i('Updating node binary to latest version...'); final binPath = await getNodeBinaryFilePath(); final binFile = File(binPath); @@ -284,31 +322,34 @@ class BinaryManager { // Create backup of existing binary if it exists if (await binFile.exists()) { - print('Creating backup of existing binary...'); + _log.d('Creating backup of existing binary...'); await binFile.copy(backupPath); - print('Backup created at: $backupPath'); + _log.d('Backup created at: $backupPath'); } try { // Download to temporary location first - final newBinary = await _downloadNodeBinary(onProgress: onProgress, isUpdate: true); + final newBinary = await _downloadNodeBinary( + onProgress: onProgress, + isUpdate: true, + ); // If download successful, replace the old binary if (await backupFile.exists()) { await backupFile.delete(); - print('Backup removed after successful update'); + _log.d('Backup removed after successful update'); } - print('Node binary updated successfully!'); + _log.i('Node binary updated successfully!'); return newBinary; } catch (e) { // If download failed, restore from backup - print('Download failed: $e'); + _log.e('Download failed', error: e); if (await backupFile.exists()) { - print('Restoring from backup...'); + _log.i('Restoring from backup...'); await backupFile.copy(binPath); await backupFile.delete(); - print('Binary restored from backup'); + _log.i('Binary restored from backup'); } rethrow; } @@ -319,23 +360,30 @@ class BinaryManager { bool isUpdate = false, }) async { // Find latest tag on GitHub - final rel = await http.get(Uri.parse('https://api.github.com/repos/$_repoOwner/$_repoName/releases/latest')); + final rel = await http.get( + Uri.parse( + 'https://api.github.com/repos/$_repoOwner/$_repoName/releases/latest', + ), + ); final tag = jsonDecode(rel.body)['tag_name'] as String; - print('found latest tag: $tag'); + _log.d('Found latest tag: $tag'); // Pick asset name final target = _targetTriple(); final extension = Platform.isWindows ? "zip" : "tar.gz"; final asset = '$_binary-$tag-$target.$extension'; - final url = 'https://github.com/$_repoOwner/$_repoName/releases/download/$tag/$asset'; + final url = + 'https://github.com/$_repoOwner/$_repoName/releases/download/$tag/$asset'; // Download final cacheDir = await _getCacheDir(); final tgz = File(p.join(cacheDir.path, asset)); // Use temporary path for extraction during updates - final tempExtractDir = isUpdate ? Directory(p.join(cacheDir.path, 'temp_update')) : cacheDir; + final tempExtractDir = isUpdate + ? Directory(p.join(cacheDir.path, 'temp_update')) + : cacheDir; if (isUpdate && await tempExtractDir.exists()) { await tempExtractDir.delete(recursive: true); @@ -350,7 +398,9 @@ class BinaryManager { final response = await client.send(request); if (response.statusCode != 200) { - throw Exception('Failed to download binary: ${response.statusCode} ${response.reasonPhrase}'); + throw Exception( + 'Failed to download binary: ${response.statusCode} ${response.reasonPhrase}', + ); } final totalBytes = response.contentLength ?? -1; @@ -380,7 +430,10 @@ class BinaryManager { // Extract to temporary directory if updating await Process.run('tar', ['-xzf', tgz.path, '-C', tempExtractDir.path]); - final tempBinPath = p.join(tempExtractDir.path, _normalizeFilename(_binary)); + final tempBinPath = p.join( + tempExtractDir.path, + _normalizeFilename(_binary), + ); final finalBinPath = await getNodeBinaryFilePath(); if (!Platform.isWindows) await Process.run('chmod', ['+x', tempBinPath]); @@ -409,10 +462,10 @@ class BinaryManager { final binPath = await getExternalMinerBinaryFilePath(); final binFile = File(binPath); - print('DEBUG: Checking for external miner at path: $binPath'); + _log.d('Checking for external miner at path: $binPath'); if (await binFile.exists() && !forceDownload) { - print('DEBUG: External miner binary already exists at $binPath'); + _log.d('External miner binary already exists at $binPath'); onProgress?.call(DownloadProgress(1, 1)); return binFile; } @@ -420,8 +473,10 @@ class BinaryManager { return await _downloadMinerBinary(onProgress: onProgress); } - static Future updateMinerBinary({void Function(DownloadProgress progress)? onProgress}) async { - print('Updating miner binary to latest version...'); + static Future updateMinerBinary({ + void Function(DownloadProgress progress)? onProgress, + }) async { + _log.i('Updating miner binary to latest version...'); final binPath = await getExternalMinerBinaryFilePath(); final binFile = File(binPath); @@ -430,31 +485,34 @@ class BinaryManager { // Create backup of existing binary if it exists if (await binFile.exists()) { - print('Creating backup of existing miner binary...'); + _log.d('Creating backup of existing miner binary...'); await binFile.copy(backupPath); - print('Backup created at: $backupPath'); + _log.d('Backup created at: $backupPath'); } try { // Download to temporary location first - final newBinary = await _downloadMinerBinary(onProgress: onProgress, isUpdate: true); + final newBinary = await _downloadMinerBinary( + onProgress: onProgress, + isUpdate: true, + ); // If download successful, replace the old binary if (await backupFile.exists()) { await backupFile.delete(); - print('Backup removed after successful update'); + _log.d('Backup removed after successful update'); } - print('Miner binary updated successfully!'); + _log.i('Miner binary updated successfully!'); return newBinary; } catch (e) { // If download failed, restore from backup - print('Download failed: $e'); + _log.e('Download failed', error: e); if (await backupFile.exists()) { - print('Restoring from backup...'); + _log.i('Restoring from backup...'); await backupFile.copy(binPath); await backupFile.delete(); - print('Binary restored from backup'); + _log.i('Binary restored from backup'); } rethrow; } @@ -464,18 +522,19 @@ class BinaryManager { void Function(DownloadProgress progress)? onProgress, bool isUpdate = false, }) async { - print('DEBUG: External miner binary download process starting...'); + _log.d('External miner binary download process starting...'); // Find latest tag on GitHub - final releaseUrl = 'https://api.github.com/repos/$_repoOwner/$_minerRepoName/releases/latest'; - print('DEBUG: Fetching latest release from: $releaseUrl'); + final releaseUrl = + 'https://api.github.com/repos/$_repoOwner/$_minerRepoName/releases/latest'; + _log.d('Fetching latest release from: $releaseUrl'); final rel = await http.get(Uri.parse(releaseUrl)); final releaseData = jsonDecode(rel.body); final tag = releaseData['tag_name'] as String; - print('DEBUG: Found latest external miner tag: $tag'); + _log.d('Found latest external miner tag: $tag'); // Pick asset name String platform; @@ -495,7 +554,8 @@ class BinaryManager { // Force x86_64 on Windows to support x64 emulation on ARM devices // unless we specifically start releasing native ARM64 Windows binaries arch = 'x86_64'; - } else if (Platform.version.contains('arm64') || Platform.version.contains('aarch64')) { + } else if (Platform.version.contains('arm64') || + Platform.version.contains('aarch64')) { arch = 'aarch64'; } else { arch = 'x86_64'; @@ -505,16 +565,17 @@ class BinaryManager { ? '$_minerReleaseBinary-$platform-$arch.exe' : '$_minerReleaseBinary-$platform-$arch'; - print('DEBUG: Looking for asset: $asset'); + _log.d('Looking for asset: $asset'); - final url = 'https://github.com/$_repoOwner/$_minerRepoName/releases/download/$tag/$asset'; + final url = + 'https://github.com/$_repoOwner/$_minerRepoName/releases/download/$tag/$asset'; // Check if the asset exists in the release final assets = releaseData['assets'] as List; - print('DEBUG: Available assets in release:'); + _log.d('Available assets in release:'); bool assetFound = false; for (var assetInfo in assets) { - print(' - ${assetInfo['name']} (${assetInfo['browser_download_url']})'); + _log.d(' - ${assetInfo['name']} (${assetInfo['browser_download_url']})'); if (assetInfo['name'] == asset) { assetFound = true; } @@ -530,20 +591,22 @@ class BinaryManager { final cacheDir = await _getCacheDir(); final tempFileName = isUpdate ? '$asset.tmp' : asset; final tempBinaryFile = File(p.join(cacheDir.path, tempFileName)); - print('DEBUG: Will download to: ${tempBinaryFile.path}'); + _log.d('Will download to: ${tempBinaryFile.path}'); final client = http.Client(); try { final request = http.Request('GET', Uri.parse(url)); final response = await client.send(request); - print('DEBUG: Download response status: ${response.statusCode}'); + _log.d('Download response status: ${response.statusCode}'); if (response.statusCode != 200) { - throw Exception('Failed to download external miner binary: ${response.statusCode} ${response.reasonPhrase}'); + throw Exception( + 'Failed to download external miner binary: ${response.statusCode} ${response.reasonPhrase}', + ); } final totalBytes = response.contentLength ?? -1; - print('DEBUG: Expected download size: $totalBytes bytes'); + _log.d('Expected download size: $totalBytes bytes'); int downloadedBytes = 0; List allBytes = []; @@ -557,7 +620,7 @@ class BinaryManager { } } await tempBinaryFile.writeAsBytes(allBytes); - print('DEBUG: Downloaded ${allBytes.length} bytes to ${tempBinaryFile.path}'); + _log.d('Downloaded ${allBytes.length} bytes to ${tempBinaryFile.path}'); if (totalBytes > 0 && downloadedBytes < totalBytes) { onProgress?.call(DownloadProgress(totalBytes, totalBytes)); @@ -570,38 +633,45 @@ class BinaryManager { // Set executable permissions on temp file if (!Platform.isWindows) { - print('DEBUG: Setting executable permissions on ${tempBinaryFile.path}'); - final chmodResult = await Process.run('chmod', ['+x', tempBinaryFile.path]); - print('DEBUG: chmod exit code: ${chmodResult.exitCode}'); + _log.d('Setting executable permissions on ${tempBinaryFile.path}'); + final chmodResult = await Process.run('chmod', [ + '+x', + tempBinaryFile.path, + ]); + _log.d('chmod exit code: ${chmodResult.exitCode}'); if (chmodResult.exitCode != 0) { - print('DEBUG: chmod stderr: ${chmodResult.stderr}'); + _log.e('chmod stderr: ${chmodResult.stderr}'); throw Exception('Failed to set executable permissions'); } } // Move to final location (atomic operation) final binPath = await getExternalMinerBinaryFilePath(); - print('DEBUG: Moving binary from ${tempBinaryFile.path} to $binPath'); + _log.d('Moving binary from ${tempBinaryFile.path} to $binPath'); // Copy instead of rename for cross-device compatibility await tempBinaryFile.copy(binPath); await tempBinaryFile.delete(); - print('DEBUG: Contents of cache directory after download:'); + _log.d('Contents of cache directory after download:'); final cacheDirContents = await cacheDir.list().toList(); for (var item in cacheDirContents) { - print(' - ${item.path}'); + _log.d(' - ${item.path}'); } // Final check final binFile = File(binPath); if (await binFile.exists()) { - print('DEBUG: External miner binary successfully created at $binPath'); + _log.i('External miner binary successfully created at $binPath'); // Save version info await _saveMinerVersion(tag); } else { - print('DEBUG: ERROR - External miner binary still not found at $binPath after download!'); - throw Exception('External miner binary not found after download at $binPath'); + _log.e( + 'External miner binary still not found at $binPath after download!', + ); + throw Exception( + 'External miner binary not found after download at $binPath', + ); } return binFile; @@ -619,12 +689,14 @@ class BinaryManager { if (await nodeKeyFile.exists()) { final stat = await nodeKeyFile.stat(); if (stat.size > 0) { - print('Node key file already exists and has content (size: ${stat.size} bytes)'); + _log.d( + 'Node key file already exists and has content (size: ${stat.size} bytes)', + ); return nodeKeyFile; } } - print('Node key file not found or empty. Generating new key...'); + _log.i('Node key file not found or empty. Generating new key...'); final nodeBinaryPath = await getNodeBinaryFilePath(); if (!await File(nodeBinaryPath).exists()) { throw Exception( @@ -633,13 +705,20 @@ class BinaryManager { } try { - final processResult = await Process.run(nodeBinaryPath, ['key', 'generate-node-key', '--file', nodeKeyFile.path]); + final processResult = await Process.run(nodeBinaryPath, [ + 'key', + 'generate-node-key', + '--file', + nodeKeyFile.path, + ]); if (processResult.exitCode == 0) { if (await nodeKeyFile.exists()) { final stat = await nodeKeyFile.stat(); if (stat.size > 0) { - print('Successfully generated node key file: ${nodeKeyFile.path} (size: ${stat.size} bytes)'); + _log.i( + 'Successfully generated node key file: ${nodeKeyFile.path} (size: ${stat.size} bytes)', + ); return nodeKeyFile; } else { throw Exception('Node key file was created but is empty'); @@ -653,20 +732,25 @@ class BinaryManager { ); } } catch (e) { - print('Error generating node key: $e'); + _log.e('Error generating node key', error: e); rethrow; } } - static String _normalizeFilename(String file) => Platform.isWindows ? "$file.exe" : file; + static String _normalizeFilename(String file) => + Platform.isWindows ? "$file.exe" : file; - static Future _getCacheDir() async => - Directory(p.join(await getQuantusHomeDirectoryPath(), 'bin')).create(recursive: true); + static Future _getCacheDir() async => Directory( + p.join(await getQuantusHomeDirectoryPath(), 'bin'), + ).create(recursive: true); - static String _home() => Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']!; + static String _home() => + Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']!; static String _targetTriple() { - final os = Platform.isMacOS ? 'apple-darwin' : (Platform.isWindows ? 'pc-windows-msvc' : 'unknown-linux-gnu'); + final os = Platform.isMacOS + ? 'apple-darwin' + : (Platform.isWindows ? 'pc-windows-msvc' : 'unknown-linux-gnu'); // Force x86_64 on Windows to ensure we download the x64 binary even on ARM devices // (since they can emulate x64, and we don't likely have a native ARM build for Windows yet) @@ -674,7 +758,11 @@ class BinaryManager { return 'x86_64-$os'; } - final arch = Platform.version.contains('arm64') || Platform.version.contains('aarch64') ? 'aarch64' : 'x86_64'; + final arch = + Platform.version.contains('arm64') || + Platform.version.contains('aarch64') + ? 'aarch64' + : 'x86_64'; return '$arch-$os'; } } diff --git a/miner-app/lib/src/services/chain_rpc_client.dart b/miner-app/lib/src/services/chain_rpc_client.dart index 54c23655..d565989e 100644 --- a/miner-app/lib/src/services/chain_rpc_client.dart +++ b/miner-app/lib/src/services/chain_rpc_client.dart @@ -2,6 +2,10 @@ import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; +import 'package:quantus_miner/src/config/miner_config.dart'; +import 'package:quantus_miner/src/utils/app_logger.dart'; + +final _log = log.withTag('ChainRpc'); class ChainInfo { final int peerCount; @@ -32,8 +36,9 @@ class ChainRpcClient { final http.Client _httpClient; int _requestId = 1; - ChainRpcClient({this.rpcUrl = 'http://127.0.0.1:9933', this.timeout = const Duration(seconds: 10)}) - : _httpClient = http.Client(); + ChainRpcClient({String? rpcUrl, this.timeout = const Duration(seconds: 10)}) + : rpcUrl = rpcUrl ?? MinerConfig.nodeRpcUrl(MinerConfig.defaultNodeRpcPort), + _httpClient = http.Client(); /// Get comprehensive chain information Future getChainInfo() async { @@ -91,7 +96,8 @@ class ChainRpcClient { bool isSyncing = false; int? targetBlock; if (syncStateResult != null) { - if (syncStateResult['currentBlock'] != null && syncStateResult['highestBlock'] != null) { + if (syncStateResult['currentBlock'] != null && + syncStateResult['highestBlock'] != null) { final current = syncStateResult['currentBlock'] as int; final highest = syncStateResult['highestBlock'] as int; @@ -119,15 +125,14 @@ class ChainRpcClient { nodeVersion: nodeVersion, ); - // Only log successful chain info - print('DEBUG: Chain connected - Peers: $peerCount, Block: $currentBlock'); + _log.d('Chain connected - Peers: $peerCount, Block: $currentBlock'); return info; } catch (e) { // Only log unexpected errors, not connection issues during startup if (!e.toString().contains('Connection refused') && !e.toString().contains('Connection reset') && !e.toString().contains('timeout')) { - print('DEBUG: getChainInfo error: $e'); + _log.w('getChainInfo error', error: e); } return null; } @@ -173,7 +178,9 @@ class ChainRpcClient { Future isSyncing() async { try { final syncState = await _rpcCall('system_syncState'); - if (syncState != null && syncState['currentBlock'] != null && syncState['highestBlock'] != null) { + if (syncState != null && + syncState['currentBlock'] != null && + syncState['highestBlock'] != null) { final current = syncState['currentBlock'] as int; final highest = syncState['highestBlock'] as int; return (highest - current) > 5; @@ -197,14 +204,22 @@ class ChainRpcClient { /// Execute a JSON-RPC call Future _rpcCall(String method, [List? params]) async { - final request = {'jsonrpc': '2.0', 'id': _requestId++, 'method': method}; - if (params != null) request['params'] = params; + final request = { + 'jsonrpc': '2.0', + 'id': _requestId++, + 'method': method, + if (params != null) 'params': params, + }; // Only print RPC calls when debugging connection issues // print('DEBUG: Making RPC call: $method with request: ${json.encode(request)}'); final response = await _httpClient - .post(Uri.parse(rpcUrl), headers: {'Content-Type': 'application/json'}, body: json.encode(request)) + .post( + Uri.parse(rpcUrl), + headers: {'Content-Type': 'application/json'}, + body: json.encode(request), + ) .timeout(timeout); if (response.statusCode == 200) { @@ -218,7 +233,9 @@ class ChainRpcClient { } else { // Don't log connection errors during startup - they're expected if (response.statusCode != 0) { - print('DEBUG: RPC HTTP error for $method: ${response.statusCode} ${response.reasonPhrase}'); + _log.w( + 'RPC HTTP error for $method: ${response.statusCode} ${response.reasonPhrase}', + ); } throw Exception('HTTP ${response.statusCode}: ${response.reasonPhrase}'); } @@ -248,7 +265,8 @@ class PollingChainRpcClient extends ChainRpcClient { void Function(ChainInfo info)? onChainInfoUpdate; void Function(String error)? onError; - PollingChainRpcClient({super.rpcUrl, super.timeout, this.pollInterval = const Duration(seconds: 3)}); + PollingChainRpcClient({super.rpcUrl, super.timeout, Duration? pollInterval}) + : pollInterval = pollInterval ?? MinerConfig.prometheusPollingInterval; /// Start polling for chain information void startPolling() { diff --git a/miner-app/lib/src/services/external_miner_api_client.dart b/miner-app/lib/src/services/external_miner_api_client.dart index b9d5b411..4a4f8cc1 100644 --- a/miner-app/lib/src/services/external_miner_api_client.dart +++ b/miner-app/lib/src/services/external_miner_api_client.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:http/http.dart' as http; +import 'package:quantus_miner/src/config/miner_config.dart'; class ExternalMinerMetrics { final double hashRate; @@ -28,7 +29,6 @@ class ExternalMinerMetrics { } class ExternalMinerApiClient { - final String baseUrl; final String metricsUrl; final Duration timeout; final http.Client _httpClient; @@ -40,16 +40,20 @@ class ExternalMinerApiClient { void Function(String error)? onError; ExternalMinerApiClient({ - this.baseUrl = 'http://127.0.0.1:9833', String? metricsUrl, this.timeout = const Duration(seconds: 5), - }) : metricsUrl = metricsUrl ?? 'http://127.0.0.1:9900/metrics', + }) : metricsUrl = + metricsUrl ?? + MinerConfig.minerMetricsUrl(MinerConfig.defaultMinerMetricsPort), _httpClient = http.Client(); - /// Start polling for metrics every second + /// Start polling for metrics void startPolling() { _pollTimer?.cancel(); - _pollTimer = Timer.periodic(const Duration(seconds: 1), (_) => _pollMetrics()); + _pollTimer = Timer.periodic( + MinerConfig.metricsPollingInterval, + (_) => _pollMetrics(), + ); } /// Stop polling for metrics @@ -64,7 +68,9 @@ class ExternalMinerApiClient { /// Get metrics from external miner Prometheus endpoint Future getMetrics() async { try { - final response = await _httpClient.get(Uri.parse(metricsUrl)).timeout(timeout); + final response = await _httpClient + .get(Uri.parse(metricsUrl)) + .timeout(timeout); if (response.statusCode == 200) { return _parsePrometheusMetrics(response.body); @@ -167,22 +173,12 @@ class ExternalMinerApiClient { } } - /// Test if the external miner is reachable - Future isReachable() async { - try { - final response = await _httpClient.get(Uri.parse(baseUrl)).timeout(const Duration(seconds: 3)); - - // Any response (even 404) means the server is running - return response.statusCode >= 200 && response.statusCode < 500; - } catch (e) { - return false; - } - } - /// Test if the metrics endpoint is available Future isMetricsAvailable() async { try { - final response = await _httpClient.get(Uri.parse(metricsUrl)).timeout(const Duration(seconds: 3)); + final response = await _httpClient + .get(Uri.parse(metricsUrl)) + .timeout(const Duration(seconds: 3)); return response.statusCode == 200; } catch (e) { diff --git a/miner-app/lib/src/services/gpu_detection_service.dart b/miner-app/lib/src/services/gpu_detection_service.dart index 420d71b2..b3d97181 100644 --- a/miner-app/lib/src/services/gpu_detection_service.dart +++ b/miner-app/lib/src/services/gpu_detection_service.dart @@ -1,7 +1,12 @@ import 'dart:io'; +import 'package:quantus_miner/src/config/miner_config.dart'; +import 'package:quantus_miner/src/utils/app_logger.dart'; + import 'binary_manager.dart'; +final _log = log.withTag('GpuDetection'); + class GpuDetectionService { /// Detects the number of GPU devices on the system by probing the miner process static Future detectGpuCount() async { @@ -10,12 +15,12 @@ class GpuDetectionService { final bin = File(binPath); if (!await bin.exists()) { - print('External miner binary not found at $binPath'); + _log.w('External miner binary not found at $binPath'); return 0; } - // Start probing from 8 down to 1 - for (int i = 8; i >= 1; i--) { + // Start probing from maxGpuProbeCount down to 1 + for (int i = MinerConfig.maxGpuProbeCount; i >= 1; i--) { try { // Use a very short duration to fail fast or succeed quickly // If it succeeds, it will take 1 second. @@ -38,18 +43,20 @@ class GpuDetectionService { // Failed. Check if we can extract the actual count from the error message to shortcut. // Message format: "❌ ERROR: Requested X GPU devices but only Y device(s) are available." final output = result.stdout.toString() + result.stderr.toString(); - final match = RegExp(r'only (\d+) device\(s\) are available').firstMatch(output); + final match = RegExp( + r'only (\d+) device\(s\) are available', + ).firstMatch(output); if (match != null) { final available = int.parse(match.group(1)!); return available; } } } catch (e) { - print('Error probing for $i GPUs: $e'); + _log.d('Error probing for $i GPUs', error: e); } } } catch (e) { - print('Error in GPU detection service: $e'); + _log.e('Error in GPU detection service', error: e); } return 0; } diff --git a/miner-app/lib/src/services/log_filter_service.dart b/miner-app/lib/src/services/log_filter_service.dart index a0ab0f04..1ace7136 100644 --- a/miner-app/lib/src/services/log_filter_service.dart +++ b/miner-app/lib/src/services/log_filter_service.dart @@ -5,7 +5,8 @@ class LogFilterService { final List criticalKeywordsDuringSync; LogFilterService({ - this.initialLinesToPrint = 50, // Increased initial lines to show more startup info + this.initialLinesToPrint = + 50, // Increased initial lines to show more startup info this.keywordsToWatch = const [ // Info level logs that users want to see by default 'info', @@ -67,16 +68,22 @@ class LogFilterService { final lowerLine = line.toLowerCase(); // Always print critical messages, regardless of sync state (after initial burst) - if (criticalKeywordsDuringSync.any((keyword) => lowerLine.contains(keyword.toLowerCase()))) { + if (criticalKeywordsDuringSync.any( + (keyword) => lowerLine.contains(keyword.toLowerCase()), + )) { return true; } if (isNodeSyncing) { // During sync, show info level logs and keywords (not just critical messages) - return keywordsToWatch.any((keyword) => lowerLine.contains(keyword.toLowerCase())); + return keywordsToWatch.any( + (keyword) => lowerLine.contains(keyword.toLowerCase()), + ); } else { // When synced (and after initial burst, and not critical), print if it matches normal keywords. - return keywordsToWatch.any((keyword) => lowerLine.contains(keyword.toLowerCase())); + return keywordsToWatch.any( + (keyword) => lowerLine.contains(keyword.toLowerCase()), + ); } } } diff --git a/miner-app/lib/src/services/log_stream_processor.dart b/miner-app/lib/src/services/log_stream_processor.dart new file mode 100644 index 00000000..5baef43f --- /dev/null +++ b/miner-app/lib/src/services/log_stream_processor.dart @@ -0,0 +1,164 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:quantus_miner/src/services/log_filter_service.dart'; +import 'package:quantus_miner/src/shared/extensions/log_string_extension.dart'; +import 'package:quantus_miner/src/utils/app_logger.dart'; + +final _log = log.withTag('LogProcessor'); + +/// Represents a single log entry from a process. +class LogEntry { + /// The log message content. + final String message; + + /// When the log was received. + final DateTime timestamp; + + /// Source identifier (e.g., 'node', 'miner', 'node-error'). + final String source; + + /// Whether this is an error-level log. + final bool isError; + + LogEntry({ + required this.message, + required this.timestamp, + required this.source, + this.isError = false, + }); + + @override + String toString() { + final timeStr = timestamp.toIso8601String().substring(11, 19); // HH:MM:SS + return '[$timeStr] [$source] $message'; + } +} + +/// Callback type for checking if node is currently syncing. +typedef SyncStateProvider = bool Function(); + +/// Processes stdout/stderr streams from a process and emits filtered LogEntries. +/// +/// Handles: +/// - Stream decoding (UTF8) +/// - Line splitting +/// - Log filtering based on keywords and sync state +/// - Error detection +class LogStreamProcessor { + final String sourceName; + final LogFilterService _filter; + final SyncStateProvider? _getSyncState; + + StreamSubscription? _stdoutSubscription; + StreamSubscription? _stderrSubscription; + + final _logController = StreamController.broadcast(); + + /// Stream of processed log entries. + Stream get logs => _logController.stream; + + /// Whether the processor is currently active. + bool get isActive => + _stdoutSubscription != null || _stderrSubscription != null; + + LogStreamProcessor({ + required this.sourceName, + SyncStateProvider? getSyncState, + }) : _filter = LogFilterService(), + _getSyncState = getSyncState; + + /// Start processing logs from a process. + /// + /// Call this after starting the process. + void attach(Process process) { + _filter.reset(); + + _stdoutSubscription = process.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen(_processStdoutLine); + + _stderrSubscription = process.stderr + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen(_processStderrLine); + + _log.d('Attached to process (PID: ${process.pid})'); + } + + /// Stop processing and release resources. + void detach() { + _stdoutSubscription?.cancel(); + _stderrSubscription?.cancel(); + _stdoutSubscription = null; + _stderrSubscription = null; + _log.d('Detached from process'); + } + + /// Close the log stream permanently. + void dispose() { + detach(); + if (!_logController.isClosed) { + _logController.close(); + } + } + + void _processStdoutLine(String line) { + final shouldPrint = _filter.shouldPrintLine( + line, + isNodeSyncing: _getSyncState?.call() ?? false, + ); + + if (shouldPrint) { + final isError = _isErrorLine(line); + final entry = LogEntry( + message: line, + timestamp: DateTime.now(), + source: isError ? '$sourceName-error' : sourceName, + isError: isError, + ); + _logController.add(entry); + + if (isError) { + _log.w('[$sourceName] $line'); + } else { + _log.d('[$sourceName] $line'); + } + } + } + + void _processStderrLine(String line) { + // stderr is always potentially important + final isError = _isErrorLine(line); + final entry = LogEntry( + message: line, + timestamp: DateTime.now(), + source: isError ? '$sourceName-error' : sourceName, + isError: isError, + ); + _logController.add(entry); + + if (isError) { + _log.w('[$sourceName] $line'); + } else { + _log.d('[$sourceName] $line'); + } + } + + bool _isErrorLine(String line) { + // Use the extension method if available for source-specific checks + if (sourceName == 'node') { + return line.isNodeError; + } else if (sourceName == 'miner') { + return line.isMinerError; + } + // Fallback generic error detection + final lower = line.toLowerCase(); + return lower.contains('error') || + lower.contains('panic') || + lower.contains('fatal') || + lower.contains('failed'); + } +} diff --git a/miner-app/lib/src/services/miner_process.dart b/miner-app/lib/src/services/miner_process.dart deleted file mode 100644 index b8c65240..00000000 --- a/miner-app/lib/src/services/miner_process.dart +++ /dev/null @@ -1,917 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:path/path.dart' as p; -import 'package:quantus_miner/src/services/prometheus_service.dart'; -import 'package:quantus_miner/src/shared/extensions/log_string_extension.dart'; - -import './binary_manager.dart'; -import './chain_rpc_client.dart'; -import './external_miner_api_client.dart'; -import './log_filter_service.dart'; -import './mining_stats_service.dart'; - -class LogEntry { - final String message; - final DateTime timestamp; - final String source; // 'node', 'quantus-miner', 'error' - - LogEntry({required this.message, required this.timestamp, required this.source}); - - @override - String toString() { - final timeStr = timestamp.toIso8601String().substring(11, 19); // HH:MM:SS - return '[$timeStr] [$source] $message'; - } -} - -/// quantus_sdk/lib/src/services/miner_process.dart -class MinerProcess { - final File bin; - final File identityPath; - final File rewardsPath; - late Process _nodeProcess; - Process? _externalMinerProcess; - late LogFilterService _stdoutFilter; - late LogFilterService _stderrFilter; - - late MiningStatsService _statsService; - late PrometheusService _prometheusService; - late ExternalMinerApiClient _externalMinerApiClient; - late PollingChainRpcClient _chainRpcClient; - - Timer? _syncStatusTimer; - final int cpuWorkers; - final int gpuDevices; - - final int externalMinerPort; - final int detectedGpuCount; - - // Track metrics state to prevent premature hashrate reset - double _lastValidHashrate = 0.0; - int _consecutiveMetricsFailures = 0; - static const int _maxConsecutiveFailures = 5; - - // Public getters for process PIDs (for cleanup tracking) - int? get nodeProcessPid { - try { - return _nodeProcess.pid; - } catch (e) { - return null; - } - } - - int? get externalMinerProcessPid { - try { - return _externalMinerProcess?.pid; - } catch (e) { - return null; - } - } - - // Stream for logs - final _logsController = StreamController.broadcast(); - Stream get logsStream => _logsController.stream; - - final Function(MiningStats stats)? onStatsUpdate; - - MinerProcess( - this.bin, - this.identityPath, - this.rewardsPath, { - this.onStatsUpdate, - this.cpuWorkers = 8, - this.gpuDevices = 0, - this.detectedGpuCount = 0, - this.externalMinerPort = 9833, - }) { - // Initialize services - _statsService = MiningStatsService(); - _prometheusService = PrometheusService(); - _stdoutFilter = LogFilterService(); - _stderrFilter = LogFilterService(); - - // Initialize external miner API client with metrics endpoint - _externalMinerApiClient = ExternalMinerApiClient( - baseUrl: 'http://127.0.0.1:$externalMinerPort', - metricsUrl: 'http://127.0.0.1:9900/metrics', // Standard metrics port - ); - - // Set up external miner API callbacks - _externalMinerApiClient.onMetricsUpdate = _handleExternalMinerMetrics; - _externalMinerApiClient.onError = _handleExternalMinerError; - - // Initialize chain RPC client - _chainRpcClient = PollingChainRpcClient(rpcUrl: 'http://127.0.0.1:9933'); - _chainRpcClient.onChainInfoUpdate = _handleChainInfoUpdate; - _chainRpcClient.onError = _handleChainRpcError; - - // Initialize stats with the configured worker count - _statsService.updateWorkers(cpuWorkers); - // Initialize stats with total CPU capacity from platform - _statsService.updateCpuCapacity(Platform.numberOfProcessors); - // Initialize stats with the configured GPU devices - _statsService.updateGpuDevices(gpuDevices); - // Initialize stats with total GPU capacity from detection - _statsService.updateGpuCapacity(detectedGpuCount); - } - - Future start() async { - // First, ensure both binaries are available - await BinaryManager.ensureNodeBinary(); - - // Cleanup any existing processes first - await _cleanupExistingNodeProcesses(); - await _cleanupExistingMinerProcesses(); - - // Cleanup database lock files if needed - await _cleanupDatabaseLocks(); - - // Ensure database directory has proper access - await _ensureDatabaseDirectoryAccess(); - - // Check if ports are available and cleanup if needed - await _ensurePortsAvailable(); - - final externalMinerBinPath = await BinaryManager.getExternalMinerBinaryFilePath(); - - await BinaryManager.ensureExternalMinerBinary(); - final externalMinerBin = File(externalMinerBinPath); - - if (!await externalMinerBin.exists()) { - throw Exception('External miner binary not found at $externalMinerBinPath'); - } - - // Start the external miner first with metrics enabled - - final minerArgs = [ - 'serve', - '--port', - externalMinerPort.toString(), - '--cpu-workers', - cpuWorkers.toString(), - '--gpu-devices', - gpuDevices.toString(), - '--metrics-port', - await _getMetricsPort().then((port) => port.toString()), - ]; - - try { - _externalMinerProcess = await Process.start(externalMinerBin.path, minerArgs); - } catch (e) { - throw Exception('Failed to start external miner: $e'); - } - - // Set up external miner log handling - _externalMinerProcess!.stdout.transform(utf8.decoder).transform(const LineSplitter()).listen((line) { - final logEntry = LogEntry(message: line, timestamp: DateTime.now(), source: 'quantus-miner'); - _logsController.add(logEntry); - print('[ext-miner] $line'); - }); - - _externalMinerProcess!.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen((line) { - final logEntry = LogEntry( - message: line, - timestamp: DateTime.now(), - source: line.isMinerError ? 'quantus-miner-error' : 'quantus-miner', - ); - _logsController.add(logEntry); - print('[ext-miner-err] $line'); - }); - - // Monitor external miner process exit - _externalMinerProcess!.exitCode.then((exitCode) { - if (exitCode != 0) { - print('External miner process exited with code: $exitCode'); - } - }); - - // Give the external miner a moment to start up - await Future.delayed(const Duration(seconds: 3)); - - // Check if external miner process is still alive - bool minerStillRunning = true; - try { - // Check if the process has exited by looking at its PID - final pid = _externalMinerProcess!.pid; - minerStillRunning = await _isProcessRunning(pid); - } catch (e) { - minerStillRunning = false; - } - - if (!minerStillRunning) { - throw Exception('External miner process died during startup'); - } - - // Test if external miner is responding on the port - try { - final testClient = HttpClient(); - testClient.connectionTimeout = const Duration(seconds: 5); - final request = await testClient.getUrl(Uri.parse('http://127.0.0.1:$externalMinerPort')); - final response = await request.close(); - await response.drain(); // Consume the response - testClient.close(); - } catch (e) { - // External miner might still be starting up - } - - // Now start the node process - final quantusHome = await BinaryManager.getQuantusHomeDirectoryPath(); - final basePath = p.join(quantusHome, 'node_data'); - await Directory(basePath).create(recursive: true); - - final nodeKeyFileFromFileSystem = await BinaryManager.getNodeKeyFile(); - if (await nodeKeyFileFromFileSystem.exists()) { - final stat = await nodeKeyFileFromFileSystem.stat(); - print('DEBUG: nodeKeyFileFromFileSystem (${nodeKeyFileFromFileSystem.path}) exists (size: ${stat.size} bytes)'); - } else { - print('DEBUG: nodeKeyFileFromFileSystem (${nodeKeyFileFromFileSystem.path}) does not exist.'); - } - - if (!await identityPath.exists()) { - throw Exception('Identity file not found: ${identityPath.path}'); - } - - // Read the rewards address from the file - String rewardsAddress; - try { - if (!await rewardsPath.exists()) { - throw Exception('Rewards address file not found: ${rewardsPath.path}'); - } - rewardsAddress = await rewardsPath.readAsString(); - rewardsAddress = rewardsAddress.trim(); // Remove any whitespace/newlines - print('DEBUG: Read rewards address from file: $rewardsAddress'); - } catch (e) { - throw Exception('Failed to read rewards address from file ${rewardsPath.path}: $e'); - } - - final List args = [ - '--base-path', - basePath, - '--node-key-file', - identityPath.path, - '--rewards-address', - rewardsAddress, - '--validator', - '--chain', - 'dirac', - '--port', - '30333', - '--prometheus-port', - '9616', - '--experimental-rpc-endpoint', - 'listen-addr=127.0.0.1:9933,methods=unsafe,cors=all', - '--name', - 'QuantusMinerGUI', - '--external-miner-url', - 'http://127.0.0.1:$externalMinerPort', - '--enable-peer-sharing', - ]; - - print('DEBUG: Executing command:\n ${bin.path} ${args.join(' ')}'); - print('DEBUG: Args: ${args.join('\n')}'); - - _nodeProcess = await Process.start(bin.path, args); - _stdoutFilter = LogFilterService(); - _stderrFilter = LogFilterService(); - // Services are now initialized in constructor - - _stdoutFilter.reset(); - _stderrFilter.reset(); - - Future syncBlockTargetWithPrometheusMetrics() async { - try { - final metrics = await _prometheusService.fetchMetrics(); - if (metrics == null || metrics.targetBlock == null) return; - if (_statsService.currentStats.targetBlock >= metrics.targetBlock!) { - return; - } - - _statsService.updateTargetBlock(metrics.targetBlock!); - - onStatsUpdate?.call(_statsService.currentStats); - } catch (e) { - print('Failed to fetch target block height: $e'); - } - } - - // Start Prometheus polling for target block (every 3 seconds) - _syncStatusTimer?.cancel(); - _syncStatusTimer = Timer.periodic(const Duration(seconds: 3), (timer) => syncBlockTargetWithPrometheusMetrics()); - - // Start external miner API polling (every second) - _externalMinerApiClient.startPolling(); - - // Wait for node to be ready before starting RPC polling - _waitForNodeReadyThenStartRpc(); - - // Process each log line - void processLogLine(String line, String streamType) { - bool shouldPrint; - if (streamType == 'stdout') { - shouldPrint = _stdoutFilter.shouldPrintLine(line, isNodeSyncing: _statsService.currentStats.isSyncing); - } else { - shouldPrint = _stderrFilter.shouldPrintLine(line, isNodeSyncing: _statsService.currentStats.isSyncing); - } - - if (shouldPrint) { - String source; - if (line.isNodeError) { - source = 'node-error'; - } else if (streamType == 'stdout') { - source = 'node'; - } else { - source = 'node'; - } - - final logEntry = LogEntry(message: line, timestamp: DateTime.now(), source: source); - _logsController.add(logEntry); - print(source == 'node' ? '[node] $line' : '[node-error] $line'); - } - } - - _nodeProcess.stdout.transform(utf8.decoder).transform(const LineSplitter()).listen((line) { - processLogLine(line, 'stdout'); - }); - - _nodeProcess.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen((line) { - processLogLine(line, 'stderr'); - }); - } - - void stop() { - print('MinerProcess: stop() called. Killing processes.'); - _syncStatusTimer?.cancel(); - _externalMinerApiClient.stopPolling(); - _chainRpcClient.stopPolling(); - - // Kill external miner process first - if (_externalMinerProcess != null) { - try { - print('MinerProcess: Attempting to kill external miner process (PID: ${_externalMinerProcess!.pid})'); - - // Try graceful termination first - _externalMinerProcess!.kill(ProcessSignal.sigterm); - - // Wait briefly for graceful shutdown - Future.delayed(const Duration(seconds: 2)).then((_) async { - // Check if process is still running and force kill if necessary - try { - if (await _isProcessRunning(_externalMinerProcess!.pid)) { - print('MinerProcess: External miner still running, force killing...'); - _externalMinerProcess!.kill(ProcessSignal.sigkill); - } - } catch (e) { - // Process is already dead, which is what we want - print('MinerProcess: External miner process already terminated'); - } - }); - } catch (e) { - print('MinerProcess: Error killing external miner process: $e'); - // Try force kill as backup - try { - _externalMinerProcess!.kill(ProcessSignal.sigkill); - } catch (e2) { - print('MinerProcess: Error force killing external miner process: $e2'); - } - } - } - - // Kill node process - try { - print('MinerProcess: Attempting to kill node process (PID: ${_nodeProcess.pid})'); - - // Try graceful termination first - _nodeProcess.kill(ProcessSignal.sigterm); - - // Wait briefly for graceful shutdown - Future.delayed(const Duration(seconds: 2)).then((_) async { - // Check if process is still running and force kill if necessary - try { - if (await _isProcessRunning(_nodeProcess.pid)) { - print('MinerProcess: Node process still running, force killing...'); - _nodeProcess.kill(ProcessSignal.sigkill); - } - } catch (e) { - // Process is already dead, which is what we want - print('MinerProcess: Node process already terminated'); - } - }); - } catch (e) { - print('MinerProcess: Error killing node process: $e'); - // Try force kill as backup - try { - _nodeProcess.kill(ProcessSignal.sigkill); - } catch (e2) { - print('MinerProcess: Error force killing node process: $e2'); - } - } - - // Close the logs stream - if (!_logsController.isClosed) { - _logsController.close(); - } - } - - /// Force stop both processes immediately with SIGKILL - void forceStop() { - print('MinerProcess: forceStop() called. Force killing processes.'); - _syncStatusTimer?.cancel(); - - final List> killFutures = []; - - // Force kill external miner - if (_externalMinerProcess != null) { - final minerPid = _externalMinerProcess!.pid; - killFutures.add(_forceKillProcess(minerPid, 'external miner')); - try { - _externalMinerProcess!.kill(ProcessSignal.sigkill); - } catch (e) { - print('MinerProcess: Error force killing external miner process: $e'); - } - _externalMinerProcess = null; - } - - // Force kill node process - try { - final nodePid = _nodeProcess.pid; - killFutures.add(_forceKillProcess(nodePid, 'node')); - _nodeProcess.kill(ProcessSignal.sigkill); - } catch (e) { - print('MinerProcess: Error force killing node process: $e'); - } - - // Wait for all kills to complete (with timeout) - Future.wait(killFutures).timeout( - const Duration(seconds: 5), - onTimeout: () { - print('MinerProcess: Force kill operations timed out'); - return []; - }, - ); - - // Close the logs stream - if (!_logsController.isClosed) { - _logsController.close(); - } - } - - /// Check if a process with the given PID is running - Future _isProcessRunning(int pid) async { - try { - if (Platform.isWindows) { - final result = await Process.run('tasklist', ['/FI', 'PID eq $pid']); - return result.stdout.toString().contains(' $pid '); - } else { - final result = await Process.run('kill', ['-0', pid.toString()]); - return result.exitCode == 0; - } - } catch (e) { - return false; - } - } - - /// Helper method to force kill a process by PID with verification - Future _forceKillProcess(int pid, String processName) async { - try { - print('MinerProcess: Force killing $processName process (PID: $pid)'); - - if (Platform.isWindows) { - final killResult = await Process.run('taskkill', ['/F', '/PID', pid.toString()]); - if (killResult.exitCode == 0) { - print('MinerProcess: Successfully force killed $processName (PID: $pid)'); - } else { - print('MinerProcess: taskkill failed for $processName (PID: $pid), exit code: ${killResult.exitCode}'); - } - - await Future.delayed(const Duration(milliseconds: 500)); - - // Verify - final checkResult = await Process.run('tasklist', ['/FI', 'PID eq $pid']); - if (checkResult.stdout.toString().contains(' $pid ')) { - print('MinerProcess: WARNING - $processName (PID: $pid) may still be running'); - // Try by name as last resort - final binaryName = processName.contains('miner') ? 'quantus-miner.exe' : 'quantus-node.exe'; - await Process.run('taskkill', ['/F', '/IM', binaryName]); - } else { - print('MinerProcess: Verified $processName (PID: $pid) is terminated'); - } - } else { - // First try SIGKILL via kill command for better reliability - final killResult = await Process.run('kill', ['-9', pid.toString()]); - - if (killResult.exitCode == 0) { - print('MinerProcess: Successfully force killed $processName (PID: $pid)'); - } else { - print('MinerProcess: kill command failed for $processName (PID: $pid), exit code: ${killResult.exitCode}'); - } - - // Wait a moment then verify the process is dead - await Future.delayed(const Duration(milliseconds: 500)); - - final checkResult = await Process.run('kill', ['-0', pid.toString()]); - if (checkResult.exitCode != 0) { - print('MinerProcess: Verified $processName (PID: $pid) is terminated'); - } else { - print('MinerProcess: WARNING - $processName (PID: $pid) may still be running'); - // Try pkill as last resort - await Process.run('pkill', ['-9', '-f', processName.contains('miner') ? 'quantus-miner' : 'quantus-node']); - } - } - } catch (e) { - print('MinerProcess: Error in _forceKillProcess for $processName: $e'); - } - } - - /// Handle external miner metrics updates - void _handleExternalMinerMetrics(ExternalMinerMetrics metrics) { - if (metrics.isHealthy && metrics.hashRate > 0) { - // Valid metrics received - _lastValidHashrate = metrics.hashRate; - _consecutiveMetricsFailures = 0; - - _statsService.updateHashrate(metrics.hashRate); - - // Update workers count from external miner if available - if (metrics.workers > 0) { - _statsService.updateWorkers(metrics.workers); - } - - // Update CPU capacity from external miner if available - if (metrics.cpuCapacity > 0) { - _statsService.updateCpuCapacity(metrics.cpuCapacity); - } - - // Update GPU devices count from external miner if available - if (metrics.gpuDevices > 0) { - _statsService.updateGpuDevices(metrics.gpuDevices); - } - - onStatsUpdate?.call(_statsService.currentStats); - } else if (metrics.hashRate == 0.0 && _lastValidHashrate > 0) { - // Received 0.0 but we have a valid hashrate - ignore it and keep the last valid one - _statsService.updateHashrate(_lastValidHashrate); - onStatsUpdate?.call(_statsService.currentStats); - } else { - // Invalid or zero metrics - _consecutiveMetricsFailures++; - - // Only reset to zero after multiple consecutive failures - if (_consecutiveMetricsFailures >= _maxConsecutiveFailures) { - _statsService.updateHashrate(0.0); - _lastValidHashrate = 0.0; - onStatsUpdate?.call(_statsService.currentStats); - } else { - // Keep the last valid hashrate during temporary issues - if (_lastValidHashrate > 0) { - _statsService.updateHashrate(_lastValidHashrate); - onStatsUpdate?.call(_statsService.currentStats); - } - } - } - } - - /// Handle external miner API errors - void _handleExternalMinerError(String error) { - _consecutiveMetricsFailures++; - - // Only reset hashrate after multiple consecutive errors - if (_consecutiveMetricsFailures >= _maxConsecutiveFailures) { - if (_statsService.currentStats.hashrate != 0.0) { - _statsService.updateHashrate(0.0); - _lastValidHashrate = 0.0; - onStatsUpdate?.call(_statsService.currentStats); - } - } - } - - /// Check if required ports are available and cleanup if needed - Future _ensurePortsAvailable() async { - // Check if external miner port (9833) is in use - if (await _isPortInUse(externalMinerPort)) { - await _killProcessOnPort(externalMinerPort); - await Future.delayed(const Duration(seconds: 1)); - - if (await _isPortInUse(externalMinerPort)) { - throw Exception('Port $externalMinerPort is still in use after cleanup attempt'); - } - } - - // Check if metrics port (9900) is in use - if (await _isPortInUse(9900)) { - await _killProcessOnPort(9900); - await Future.delayed(const Duration(seconds: 1)); - - if (await _isPortInUse(9900)) { - final altMetricsPort = await _findAvailablePort(9900); - if (altMetricsPort != 9900) { - // Update the metrics URL for the API client - _externalMinerApiClient = ExternalMinerApiClient( - baseUrl: 'http://127.0.0.1:$externalMinerPort', - metricsUrl: 'http://127.0.0.1:$altMetricsPort/metrics', - ); - _externalMinerApiClient.onMetricsUpdate = _handleExternalMinerMetrics; - _externalMinerApiClient.onError = _handleExternalMinerError; - } - } - } - } - - /// Find an available port starting from the given port - Future _findAvailablePort(int startPort) async { - for (int port = startPort; port <= startPort + 10; port++) { - if (!(await _isPortInUse(port))) { - return port; - } - } - return startPort; // Return original if no alternative found - } - - /// Check if a port is currently in use - Future _isPortInUse(int port) async { - try { - if (Platform.isWindows) { - final result = await Process.run('netstat', ['-ano']); - return result.exitCode == 0 && result.stdout.toString().contains(':$port'); - } else { - final result = await Process.run('lsof', ['-i', ':$port']); - return result.exitCode == 0 && result.stdout.toString().isNotEmpty; - } - } catch (e) { - // lsof might not be available, try netstat as fallback - try { - final result = await Process.run('netstat', ['-an']); - return result.stdout.toString().contains(':$port'); - } catch (e2) { - print('DEBUG: Could not check port $port availability: $e2'); - return false; - } - } - } - - /// Kill process using a specific port - Future _killProcessOnPort(int port) async { - try { - if (Platform.isWindows) { - final result = await Process.run('netstat', ['-ano']); - if (result.exitCode == 0) { - final lines = result.stdout.toString().split('\n'); - for (final line in lines) { - if (line.contains(':$port')) { - final parts = line.trim().split(RegExp(r'\s+')); - if (parts.isNotEmpty) { - final pid = parts.last; - // Verify it's a valid PID number - if (int.tryParse(pid) != null) { - await Process.run('taskkill', ['/F', '/PID', pid]); - } - } - } - } - } - } else { - // Find process using the port - final result = await Process.run('lsof', ['-ti', ':$port']); - if (result.exitCode == 0) { - final pids = result.stdout.toString().trim().split('\n'); - for (final pid in pids) { - if (pid.isNotEmpty) { - await Process.run('kill', ['-9', pid.trim()]); - } - } - } - } - } catch (e) { - // Ignore cleanup errors - } - } - - /// Get the metrics port to use (either 9900 or alternative) - Future _getMetricsPort() async { - if (await _isPortInUse(9900)) { - return await _findAvailablePort(9901); - } - return 9900; - } - - /// Cleanup any existing quantus-miner processes - Future _cleanupExistingMinerProcesses() async { - try { - if (Platform.isWindows) { - try { - await Process.run('taskkill', ['/F', '/IM', 'quantus-miner.exe']); - await Future.delayed(const Duration(seconds: 2)); - } catch (_) { - // Process might not exist - } - } else { - // Find all quantus-miner processes - final result = await Process.run('pgrep', ['-f', 'quantus-miner']); - if (result.exitCode == 0) { - final pids = result.stdout.toString().trim().split('\n'); - for (final pid in pids) { - if (pid.isNotEmpty) { - try { - // Try graceful termination first - await Process.run('kill', ['-15', pid.trim()]); - await Future.delayed(const Duration(seconds: 1)); - - // Check if still running, force kill if needed - final checkResult = await Process.run('kill', ['-0', pid.trim()]); - if (checkResult.exitCode == 0) { - await Process.run('kill', ['-9', pid.trim()]); - } - } catch (e) { - // Ignore cleanup errors - } - } - } - - // Wait a moment for processes to fully terminate - await Future.delayed(const Duration(seconds: 2)); - } - } - } catch (e) { - // Ignore cleanup errors - } - } - - /// Cleanup any existing quantus-node processes - Future _cleanupExistingNodeProcesses() async { - try { - if (Platform.isWindows) { - try { - await Process.run('taskkill', ['/F', '/IM', 'quantus-node.exe']); - await Future.delayed(const Duration(seconds: 2)); - } catch (_) { - // Process might not exist - } - } else { - // Find all quantus-node processes - final result = await Process.run('pgrep', ['-f', 'quantus-node']); - if (result.exitCode == 0) { - final pids = result.stdout.toString().trim().split('\n'); - for (final pid in pids) { - if (pid.isNotEmpty) { - try { - // Try graceful termination first - await Process.run('kill', ['-15', pid.trim()]); - await Future.delayed(const Duration(seconds: 2)); - - // Check if still running, force kill if needed - final checkResult = await Process.run('kill', ['-0', pid.trim()]); - if (checkResult.exitCode == 0) { - await Process.run('kill', ['-9', pid.trim()]); - } - } catch (e) { - // Ignore cleanup errors - } - } - } - - // Wait a moment for processes to fully terminate - await Future.delayed(const Duration(seconds: 3)); - } - } - } catch (e) { - // Ignore cleanup errors - } - } - - /// Cleanup database lock files that may prevent node startup - Future _cleanupDatabaseLocks() async { - try { - // Get the quantus home directory path - final quantusHome = await BinaryManager.getQuantusHomeDirectoryPath(); - final lockFilePath = '$quantusHome/node_data/chains/dirac/db/full/LOCK'; - final lockFile = File(lockFilePath); - - if (await lockFile.exists()) { - // At this point node processes should already be cleaned up - // Safe to remove the stale lock file - await lockFile.delete(); - } - - // Also check for other potential lock files - final dbDir = Directory('$quantusHome/node_data/chains/dirac/db/full'); - if (await dbDir.exists()) { - await for (final entity in dbDir.list()) { - if (entity is File && entity.path.contains('LOCK')) { - try { - await entity.delete(); - } catch (e) { - // Ignore cleanup errors - } - } - } - } - } catch (e) { - // Ignore cleanup errors - } - } - - /// Check and fix database directory permissions - Future _ensureDatabaseDirectoryAccess() async { - try { - final quantusHome = await BinaryManager.getQuantusHomeDirectoryPath(); - final dbPath = '$quantusHome/node_data/chains/dirac/db'; - final dbDir = Directory(dbPath); - - // Create the directory if it doesn't exist - if (!await dbDir.exists()) { - await dbDir.create(recursive: true); - } - - // Check if directory is writable - final testFile = File('$dbPath/test_write_access'); - try { - await testFile.writeAsString('test'); - await testFile.delete(); - } catch (e) { - // Try to fix permissions - try { - await Process.run('chmod', ['-R', '755', dbPath]); - } catch (permError) { - // Ignore permission fix errors - } - } - } catch (e) { - // Ignore directory access errors - } - } - - /// Handle chain RPC information updates - /// Wait for the node RPC to be ready, then start polling - Future _waitForNodeReadyThenStartRpc() async { - print('DEBUG: Waiting for node RPC to be ready...'); - - // Try to connect to RPC endpoint with exponential backoff - int attempts = 0; - const maxAttempts = 20; // Up to ~2 minutes of retries - Duration delay = const Duration(seconds: 2); - - while (attempts < maxAttempts) { - try { - final isReady = await _chainRpcClient.isReachable(); - if (isReady) { - print('DEBUG: Node RPC is ready! Starting chain RPC polling...'); - _chainRpcClient.startPolling(); - return; - } - } catch (e) { - // Expected during startup - } - - attempts++; - print('DEBUG: Node RPC not ready yet (attempt $attempts/$maxAttempts), waiting ${delay.inSeconds}s...'); - - await Future.delayed(delay); - - // Exponential backoff, but cap at 10 seconds - if (delay.inSeconds < 10) { - delay = Duration(seconds: (delay.inSeconds * 1.5).round()); - } - } - - print('DEBUG: Failed to connect to node RPC after $maxAttempts attempts. Will retry with polling...'); - // Start polling anyway - the error handling in RPC client will manage failures - _chainRpcClient.startPolling(); - } - - void _handleChainInfoUpdate(ChainInfo info) { - print('DEBUG: Successfully received chain info - Peers: ${info.peerCount}, Block: ${info.currentBlock}'); - - // Update peer count from RPC (most accurate) - if (info.peerCount >= 0) { - _statsService.updatePeerCount(info.peerCount); - print('DEBUG: Updated peer count to: ${info.peerCount}'); - } - - // Update chain name from RPC - _statsService.updateChainName(info.chainName); - - // Always update current block and target block from RPC (most authoritative) - _statsService.setSyncingState(info.isSyncing, info.currentBlock, info.targetBlock ?? info.currentBlock); - print( - 'DEBUG: Updated blocks - current: ${info.currentBlock}, target: ${info.targetBlock ?? info.currentBlock}, syncing: ${info.isSyncing}, chain: ${info.chainName}', - ); - - onStatsUpdate?.call(_statsService.currentStats); - } - - /// Handle chain RPC errors - void _handleChainRpcError(String error) { - // Only log significant RPC errors, not connection issues during startup - if (!error.contains('Connection refused') && !error.contains('timeout')) { - print('Chain RPC error: $error'); - } - } - - /// Dispose of resources - void dispose() { - _syncStatusTimer?.cancel(); - _externalMinerApiClient.dispose(); - _chainRpcClient.dispose(); - } -} diff --git a/miner-app/lib/src/services/miner_process_manager.dart b/miner-app/lib/src/services/miner_process_manager.dart new file mode 100644 index 00000000..2e258f85 --- /dev/null +++ b/miner-app/lib/src/services/miner_process_manager.dart @@ -0,0 +1,142 @@ +import 'dart:io'; + +import 'package:quantus_miner/src/models/miner_error.dart'; +import 'package:quantus_miner/src/services/base_process_manager.dart'; +import 'package:quantus_miner/src/services/process_cleanup_service.dart'; +import 'package:quantus_miner/src/utils/app_logger.dart'; + +final _log = log.withTag('ExternalMiner'); + +/// Configuration for starting the external miner process. +class ExternalMinerConfig { + /// Path to the miner binary. + final File binary; + + /// Address and port of the node's QUIC endpoint (e.g., "127.0.0.1:9833"). + final String nodeAddress; + + /// Number of CPU worker threads. + final int cpuWorkers; + + /// Number of GPU devices to use. + final int gpuDevices; + + /// Port for the miner's Prometheus metrics endpoint. + final int metricsPort; + + ExternalMinerConfig({ + required this.binary, + required this.nodeAddress, + this.cpuWorkers = 8, + this.gpuDevices = 0, + this.metricsPort = 9900, + }); +} + +/// Manages the quantus-miner (external miner) process lifecycle. +/// +/// Responsibilities: +/// - Starting the miner process with proper arguments +/// - Monitoring process health and exit +/// - Stopping the process gracefully or forcefully +/// - Emitting log entries and error events +class MinerProcessManager extends BaseProcessManager { + @override + TaggedLoggerWrapper get log => _log; + + @override + String get processName => 'miner'; + + @override + MinerError createStartupError(dynamic error, [StackTrace? stackTrace]) { + return MinerError.minerStartupFailed(error, stackTrace); + } + + @override + MinerError createCrashError(int exitCode) { + return MinerError.minerCrashed(exitCode); + } + + MinerProcessManager() { + initLogProcessor('miner'); + } + + /// Start the miner process. + /// + /// Throws an exception if startup fails. + Future start(ExternalMinerConfig config) async { + if (isRunning) { + log.w('Miner already running (PID: $pid)'); + return; + } + + intentionalStop = false; + + // Validate binary exists + if (!await config.binary.exists()) { + final error = MinerError.minerStartupFailed( + 'Miner binary not found: ${config.binary.path}', + ); + errorController.add(error); + throw Exception(error.message); + } + + // Build command arguments + final args = _buildArgs(config); + + log.i('Starting miner...'); + log.d('Command: ${config.binary.path} ${args.join(' ')}'); + + try { + final proc = await Process.start(config.binary.path, args); + attachProcess(proc); + + // Monitor for unexpected exit + proc.exitCode.then(handleExit); + + // Verify it started successfully by waiting briefly + await Future.delayed(const Duration(seconds: 2)); + + // Check if process is still running + // We just attached, so pid should be available + final processPid = pid; + if (processPid != null) { + final stillRunning = await ProcessCleanupService.isProcessRunning( + processPid, + ); + if (!stillRunning) { + final error = MinerError.minerStartupFailed( + 'Miner died during startup', + ); + errorController.add(error); + clearProcess(); + throw Exception(error.message); + } + } + + log.i('Miner started (PID: $pid)'); + } catch (e, st) { + if (e.toString().contains('Miner died during startup')) { + rethrow; + } + final error = MinerError.minerStartupFailed(e, st); + errorController.add(error); + clearProcess(); + rethrow; + } + } + + List _buildArgs(ExternalMinerConfig config) { + return [ + 'serve', // Subcommand required by new miner CLI + '--node-addr', + config.nodeAddress, + '--cpu-workers', + config.cpuWorkers.toString(), + '--gpu-devices', + config.gpuDevices.toString(), + '--metrics-port', + config.metricsPort.toString(), + ]; + } +} diff --git a/miner-app/lib/src/services/miner_settings_service.dart b/miner-app/lib/src/services/miner_settings_service.dart index a6f873cf..82663dd7 100644 --- a/miner-app/lib/src/services/miner_settings_service.dart +++ b/miner-app/lib/src/services/miner_settings_service.dart @@ -1,11 +1,16 @@ import 'dart:io'; +import 'package:quantus_miner/src/config/miner_config.dart'; import 'package:quantus_miner/src/services/binary_manager.dart'; +import 'package:quantus_miner/src/utils/app_logger.dart'; import 'package:shared_preferences/shared_preferences.dart'; +final _log = log.withTag('Settings'); + class MinerSettingsService { static const String _keyCpuWorkers = 'cpu_workers'; static const String _keyGpuDevices = 'gpu_devices'; + static const String _keyChainId = 'chain_id'; Future saveCpuWorkers(int cpuWorkers) async { final prefs = await SharedPreferences.getInstance(); @@ -27,8 +32,35 @@ class MinerSettingsService { return prefs.getInt(_keyGpuDevices); } + /// Save the selected chain ID. + Future saveChainId(String chainId) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_keyChainId, chainId); + } + + /// Get the saved chain ID, returns default if not set. + Future getChainId() async { + final prefs = await SharedPreferences.getInstance(); + final savedChainId = prefs.getString(_keyChainId); + if (savedChainId == null) { + return MinerConfig.defaultChainId; + } + // Validate that the chain ID is still valid + final validIds = MinerConfig.availableChains.map((c) => c.id).toList(); + if (!validIds.contains(savedChainId)) { + return MinerConfig.defaultChainId; + } + return savedChainId; + } + + /// Get the ChainConfig for the saved chain ID. + Future getChainConfig() async { + final chainId = await getChainId(); + return MinerConfig.getChainById(chainId); + } + Future logout() async { - print('Starting app logout/reset...'); + _log.i('Starting app logout/reset...'); // 1. Delete node identity file (node_key.p2p) try { @@ -36,12 +68,12 @@ class MinerSettingsService { final identityFile = File('$quantusHome/node_key.p2p'); if (await identityFile.exists()) { await identityFile.delete(); - print('✅ Node identity file deleted: ${identityFile.path}'); + _log.i('✅ Node identity file deleted: ${identityFile.path}'); } else { - print('ℹ️ Node identity file not found, skipping deletion.'); + _log.d('ℹ️ Node identity file not found, skipping deletion.'); } } catch (e) { - print('❌ Error deleting node identity file: $e'); + _log.e('❌ Error deleting node identity file', error: e); } // 2. Delete rewards address file @@ -50,12 +82,12 @@ class MinerSettingsService { final rewardsFile = File('$quantusHome/rewards-address.txt'); if (await rewardsFile.exists()) { await rewardsFile.delete(); - print('✅ Rewards address file deleted: ${rewardsFile.path}'); + _log.i('✅ Rewards address file deleted: ${rewardsFile.path}'); } else { - print('ℹ️ Rewards address file not found, skipping deletion.'); + _log.d('ℹ️ Rewards address file not found, skipping deletion.'); } } catch (e) { - print('❌ Error deleting rewards address file: $e'); + _log.e('❌ Error deleting rewards address file', error: e); } // 3. Delete node binary @@ -64,26 +96,27 @@ class MinerSettingsService { final binaryFile = File(nodeBinaryPath); if (await binaryFile.exists()) { await binaryFile.delete(); - print('✅ Node binary file deleted: ${binaryFile.path}'); + _log.i('✅ Node binary file deleted: ${binaryFile.path}'); } else { - print('ℹ️ Node binary file not found, skipping deletion.'); + _log.d('ℹ️ Node binary file not found, skipping deletion.'); } } catch (e) { - print('❌ Error deleting node binary file: $e'); + _log.e('❌ Error deleting node binary file', error: e); } // 4. Delete external miner binary try { - final minerBinaryPath = await BinaryManager.getExternalMinerBinaryFilePath(); + final minerBinaryPath = + await BinaryManager.getExternalMinerBinaryFilePath(); final minerFile = File(minerBinaryPath); if (await minerFile.exists()) { await minerFile.delete(); - print('✅ External miner binary deleted: ${minerFile.path}'); + _log.i('✅ External miner binary deleted: ${minerFile.path}'); } else { - print('ℹ️ External miner binary not found, skipping deletion.'); + _log.d('ℹ️ External miner binary not found, skipping deletion.'); } } catch (e) { - print('❌ Error deleting external miner binary: $e'); + _log.e('❌ Error deleting external miner binary', error: e); } // 5. Delete node data directory (blockchain data) @@ -92,12 +125,12 @@ class MinerSettingsService { final nodeDataDir = Directory('$quantusHome/node_data'); if (await nodeDataDir.exists()) { await nodeDataDir.delete(recursive: true); - print('✅ Node data directory deleted: ${nodeDataDir.path}'); + _log.i('✅ Node data directory deleted: ${nodeDataDir.path}'); } else { - print('ℹ️ Node data directory not found, skipping deletion.'); + _log.d('ℹ️ Node data directory not found, skipping deletion.'); } } catch (e) { - print('❌ Error deleting node data directory: $e'); + _log.e('❌ Error deleting node data directory', error: e); } // 6. Clean up bin directory and leftover files @@ -106,25 +139,27 @@ class MinerSettingsService { final binDir = Directory('$quantusHome/bin'); if (await binDir.exists()) { // Remove any leftover tar.gz files - final tarFiles = binDir.listSync().where((file) => file.path.endsWith('.tar.gz')); + final tarFiles = binDir.listSync().where( + (file) => file.path.endsWith('.tar.gz'), + ); for (var file in tarFiles) { await file.delete(); - print('✅ Cleaned up archive: ${file.path}'); + _log.i('✅ Cleaned up archive: ${file.path}'); } // Try to remove bin directory if it's empty try { await binDir.delete(); - print('✅ Empty bin directory removed: ${binDir.path}'); + _log.i('✅ Empty bin directory removed: ${binDir.path}'); } catch (e) { // Directory not empty, that's fine - print('ℹ️ Bin directory not empty, keeping it.'); + _log.d('ℹ️ Bin directory not empty, keeping it.'); } } else { - print('ℹ️ Bin directory not found, skipping cleanup.'); + _log.d('ℹ️ Bin directory not found, skipping cleanup.'); } } catch (e) { - print('❌ Error cleaning up bin directory: $e'); + _log.e('❌ Error cleaning up bin directory', error: e); } // 7. Try to remove the entire .quantus directory if it's empty @@ -134,25 +169,25 @@ class MinerSettingsService { if (await quantusDir.exists()) { try { await quantusDir.delete(); - print('✅ Removed empty .quantus directory: $quantusHome'); + _log.i('✅ Removed empty .quantus directory: $quantusHome'); } catch (e) { // Directory not empty, that's fine - print('ℹ️ .quantus directory not empty, keeping it.'); + _log.d('ℹ️ .quantus directory not empty, keeping it.'); } } } catch (e) { - print('❌ Error removing .quantus directory: $e'); + _log.e('❌ Error removing .quantus directory', error: e); } // 8. Clear SharedPreferences try { final prefs = await SharedPreferences.getInstance(); await prefs.clear(); - print('✅ SharedPreferences cleared'); + _log.i('✅ SharedPreferences cleared'); } catch (e) { - print('❌ Error clearing SharedPreferences: $e'); + _log.e('❌ Error clearing SharedPreferences', error: e); } - print('🎉 App logout/reset complete! You can now go through setup again.'); + _log.i('🎉 App logout/reset complete!'); } } diff --git a/miner-app/lib/src/services/mining_orchestrator.dart b/miner-app/lib/src/services/mining_orchestrator.dart new file mode 100644 index 00000000..e89474d2 --- /dev/null +++ b/miner-app/lib/src/services/mining_orchestrator.dart @@ -0,0 +1,660 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:quantus_miner/src/config/miner_config.dart'; +import 'package:quantus_miner/src/models/miner_error.dart'; +import 'package:quantus_miner/src/services/chain_rpc_client.dart'; +import 'package:quantus_miner/src/services/external_miner_api_client.dart'; +import 'package:quantus_miner/src/services/log_stream_processor.dart'; +import 'package:quantus_miner/src/services/miner_process_manager.dart'; +import 'package:quantus_miner/src/services/mining_stats_service.dart'; +import 'package:quantus_miner/src/services/node_process_manager.dart'; +import 'package:quantus_miner/src/services/process_cleanup_service.dart'; +import 'package:quantus_miner/src/services/prometheus_service.dart'; +import 'package:quantus_miner/src/utils/app_logger.dart'; + +final _log = log.withTag('Orchestrator'); + +/// Current state of the mining orchestrator. +enum MiningState { + /// Not started, ready to begin. + idle, + + /// Node is starting up. + startingNode, + + /// Node is running, waiting for RPC to be ready. + waitingForRpc, + + /// Node is running (RPC ready), miner not started. + nodeRunning, + + /// Miner is starting up. + startingMiner, + + /// Both node and miner are running, mining is active. + mining, + + /// Stopping miner only. + stoppingMiner, + + /// Currently stopping everything. + stopping, + + /// An error occurred. + error, +} + +/// Configuration for starting a mining session. +class MiningSessionConfig { + /// Path to the node binary. + final File nodeBinary; + + /// Path to the miner binary. + final File minerBinary; + + /// Path to the node identity key file. + final File identityFile; + + /// Path to the rewards address file. + final File rewardsFile; + + /// Chain ID to connect to. + final String chainId; + + /// Number of CPU worker threads. + final int cpuWorkers; + + /// Number of GPU devices to use. + final int gpuDevices; + + /// Detected GPU count for stats. + final int detectedGpuCount; + + /// Port for QUIC miner connection. + final int minerListenPort; + + MiningSessionConfig({ + required this.nodeBinary, + required this.minerBinary, + required this.identityFile, + required this.rewardsFile, + this.chainId = 'dev', + this.cpuWorkers = 8, + this.gpuDevices = 0, + this.detectedGpuCount = 0, + this.minerListenPort = 9833, + }); +} + +/// Orchestrates the complete mining workflow. +/// +/// Coordinates: +/// - Node process lifecycle +/// - Miner process lifecycle +/// - Stats collection from multiple sources +/// - Error handling and crash detection +/// +/// This is the main entry point for mining operations and replaces +/// the old monolithic MinerProcess class. +class MiningOrchestrator { + // Process managers + final NodeProcessManager _nodeManager = NodeProcessManager(); + final MinerProcessManager _minerManager = MinerProcessManager(); + + // API clients for stats + late ExternalMinerApiClient _minerApiClient; + late PollingChainRpcClient _chainRpcClient; + late PrometheusService _prometheusService; + + // Stats + final MiningStatsService _statsService = MiningStatsService(); + + // State + MiningState _state = MiningState.idle; + Timer? _prometheusTimer; + int _actualMetricsPort = MinerConfig.defaultMinerMetricsPort; + + // Hashrate tracking for resilience + double _lastValidHashrate = 0.0; + int _consecutiveMetricsFailures = 0; + + // Stream controllers + final _logsController = StreamController.broadcast(); + final _statsController = StreamController.broadcast(); + final _errorController = StreamController.broadcast(); + final _stateController = StreamController.broadcast(); + + // Subscriptions + StreamSubscription? _nodeLogsSubscription; + StreamSubscription? _minerLogsSubscription; + StreamSubscription? _nodeErrorSubscription; + StreamSubscription? _minerErrorSubscription; + + // ============================================================ + // Public API + // ============================================================ + + /// Current mining state. + MiningState get state => _state; + + /// Stream of log entries from both node and miner. + Stream get logsStream => _logsController.stream; + + /// Stream of mining statistics updates. + Stream get statsStream => _statsController.stream; + + /// Stream of errors (crashes, startup failures, etc.). + Stream get errorStream => _errorController.stream; + + /// Stream of state changes. + Stream get stateStream => _stateController.stream; + + /// Current mining statistics. + MiningStats get currentStats => _statsService.currentStats; + + /// Whether mining is currently active. + bool get isMining => _state == MiningState.mining; + + /// Whether the node is running (with or without miner). + bool get isNodeRunning => + _state == MiningState.nodeRunning || + _state == MiningState.startingMiner || + _state == MiningState.mining || + _state == MiningState.stoppingMiner; + + /// Whether the orchestrator is in any running state. + bool get isRunning => + _state != MiningState.idle && _state != MiningState.error; + + /// Node process PID, if running. + int? get nodeProcessPid => _nodeManager.pid; + + /// Miner process PID, if running. + int? get minerProcessPid => _minerManager.pid; + + // Store config for later use when starting miner separately + MiningSessionConfig? _currentConfig; + + MiningOrchestrator() { + _initializeApiClients(); + _setupNodeSyncCallback(); + _subscribeToProcessEvents(); + } + + /// Start mining with the given configuration (starts both node and miner). + /// + /// This will: + /// 1. Cleanup any existing processes + /// 2. Ensure ports are available + /// 3. Start the node and wait for RPC + /// 4. Start the miner + /// 5. Begin polling for stats + Future start(MiningSessionConfig config) async { + await startNode(config); + if (_state == MiningState.nodeRunning) { + await startMiner(); + } + } + + /// Start only the node (without the miner). + /// + /// Use this to enable balance queries and chain sync without mining. + Future startNode(MiningSessionConfig config) async { + if (_state != MiningState.idle && _state != MiningState.error) { + _log.w('Cannot start node: already in state $_state'); + return; + } + + _currentConfig = config; + + try { + // Initialize stats with worker counts + _statsService.updateWorkers(config.cpuWorkers); + _statsService.updateCpuCapacity(Platform.numberOfProcessors); + _statsService.updateGpuDevices(config.gpuDevices); + _statsService.updateGpuCapacity(config.detectedGpuCount); + _emitStats(); + + // Perform pre-start cleanup + _setState(MiningState.startingNode); + await ProcessCleanupService.performPreStartCleanup(config.chainId); + + // Ensure ports are available + final ports = await ProcessCleanupService.ensurePortsAvailable( + quicPort: config.minerListenPort, + metricsPort: MinerConfig.defaultMinerMetricsPort, + ); + _actualMetricsPort = ports['metrics']!; + _updateMetricsClient(); + + // Read rewards address + final rewardsAddress = await _readRewardsAddress(config.rewardsFile); + + // Start node + await _nodeManager.start( + NodeConfig( + binary: config.nodeBinary, + identityFile: config.identityFile, + rewardsAddress: rewardsAddress, + chainId: config.chainId, + minerListenPort: config.minerListenPort, + ), + ); + + // Wait for node RPC to be ready + _setState(MiningState.waitingForRpc); + await _waitForNodeRpc(); + + // Start chain RPC polling (for balance, sync status, etc.) + _chainRpcClient.startPolling(); + + // Start Prometheus polling for target block + _prometheusTimer?.cancel(); + _prometheusTimer = Timer.periodic( + MinerConfig.prometheusPollingInterval, + (_) => _fetchPrometheusMetrics(), + ); + + _setState(MiningState.nodeRunning); + _log.i('Node started successfully'); + } catch (e, st) { + _log.e('Failed to start node', error: e, stackTrace: st); + _setState(MiningState.error); + await _stopInternal(); + rethrow; + } + } + + /// Update miner settings (CPU workers, GPU devices). + /// Call this before startMiner() if settings have changed. + void updateMinerSettings({int? cpuWorkers, int? gpuDevices}) { + if (_currentConfig == null) { + _log.w('Cannot update settings: no config available'); + return; + } + + _currentConfig = MiningSessionConfig( + nodeBinary: _currentConfig!.nodeBinary, + minerBinary: _currentConfig!.minerBinary, + identityFile: _currentConfig!.identityFile, + rewardsFile: _currentConfig!.rewardsFile, + chainId: _currentConfig!.chainId, + cpuWorkers: cpuWorkers ?? _currentConfig!.cpuWorkers, + gpuDevices: gpuDevices ?? _currentConfig!.gpuDevices, + detectedGpuCount: _currentConfig!.detectedGpuCount, + minerListenPort: _currentConfig!.minerListenPort, + ); + + // Update stats to reflect new settings + _statsService.updateWorkers(_currentConfig!.cpuWorkers); + _statsService.updateGpuDevices(_currentConfig!.gpuDevices); + _emitStats(); + + _log.i( + 'Miner settings updated: cpuWorkers=${_currentConfig!.cpuWorkers}, gpuDevices=${_currentConfig!.gpuDevices}', + ); + } + + /// Start the miner (node must already be running). + Future startMiner() async { + if (_state != MiningState.nodeRunning) { + _log.w('Cannot start miner: node not running (state: $_state)'); + return; + } + + if (_currentConfig == null) { + _log.e('Cannot start miner: no config available'); + return; + } + + final config = _currentConfig!; + + try { + _setState(MiningState.startingMiner); + + await _minerManager.start( + ExternalMinerConfig( + binary: config.minerBinary, + nodeAddress: '${MinerConfig.localhost}:${config.minerListenPort}', + cpuWorkers: config.cpuWorkers, + gpuDevices: config.gpuDevices, + metricsPort: _actualMetricsPort, + ), + ); + + // Start miner metrics polling + _minerApiClient.startPolling(); + + // Update stats to reflect miner is running + _statsService.setMinerRunning(true); + _emitStats(); + + _setState(MiningState.mining); + _log.i('Miner started successfully'); + } catch (e, st) { + _log.e('Failed to start miner', error: e, stackTrace: st); + _statsService.setMinerRunning(false); + _setState(MiningState.nodeRunning); // Revert to node-only state + rethrow; + } + } + + /// Stop only the miner (keep node running). + Future stopMiner() async { + if (_state != MiningState.mining) { + _log.w('Cannot stop miner: not mining (state: $_state)'); + return; + } + + _log.i('Stopping miner...'); + _setState(MiningState.stoppingMiner); + + _minerApiClient.stopPolling(); + await _minerManager.stop(); + + // Update stats to reflect miner is stopped + _statsService.setMinerRunning(false); + _resetStats(); + _setState(MiningState.nodeRunning); + _log.i('Miner stopped, node still running'); + } + + /// Stop everything (node and miner) gracefully. + Future stop() async { + if (_state == MiningState.idle) { + return; + } + + _log.i('Stopping everything...'); + _setState(MiningState.stopping); + await _stopInternal(); + _setState(MiningState.idle); + _resetStats(); + _currentConfig = null; + _log.i('All processes stopped'); + } + + /// Stop only the node (and miner if running). + Future stopNode() async { + if (!isNodeRunning && + _state != MiningState.startingNode && + _state != MiningState.waitingForRpc) { + _log.w('Cannot stop node: not running (state: $_state)'); + return; + } + + _log.i('Stopping node...'); + _setState(MiningState.stopping); + await _stopInternal(); + _setState(MiningState.idle); + _resetStats(); + _currentConfig = null; + _log.i('Node stopped'); + } + + /// Force stop everything immediately. + void forceStop() { + _log.i('Force stopping everything...'); + _setState(MiningState.stopping); + + _stopPolling(); + _minerManager.forceStop(); + _nodeManager.forceStop(); + + _setState(MiningState.idle); + _resetStats(); + _currentConfig = null; + _log.i('Force stopped'); + } + + /// Dispose of all resources. + void dispose() { + forceStop(); + + _nodeLogsSubscription?.cancel(); + _minerLogsSubscription?.cancel(); + _nodeErrorSubscription?.cancel(); + _minerErrorSubscription?.cancel(); + + _nodeManager.dispose(); + _minerManager.dispose(); + _minerApiClient.dispose(); + _chainRpcClient.dispose(); + + _logsController.close(); + _statsController.close(); + _errorController.close(); + _stateController.close(); + } + + // ============================================================ + // Internal Implementation + // ============================================================ + + void _initializeApiClients() { + _minerApiClient = ExternalMinerApiClient( + metricsUrl: MinerConfig.minerMetricsUrl( + MinerConfig.defaultMinerMetricsPort, + ), + ); + _minerApiClient.onMetricsUpdate = _handleMinerMetrics; + _minerApiClient.onError = _handleMinerMetricsError; + + _chainRpcClient = PollingChainRpcClient(); + _chainRpcClient.onChainInfoUpdate = _handleChainInfo; + _chainRpcClient.onError = _handleChainRpcError; + + _prometheusService = PrometheusService(); + } + + void _setupNodeSyncCallback() { + _nodeManager.getSyncState = () => _statsService.currentStats.isSyncing; + } + + void _subscribeToProcessEvents() { + // Forward node logs + _nodeLogsSubscription = _nodeManager.logs.listen((entry) { + _logsController.add(entry); + }); + + // Forward miner logs + _minerLogsSubscription = _minerManager.logs.listen((entry) { + _logsController.add(entry); + }); + + // Forward node errors + _nodeErrorSubscription = _nodeManager.errors.listen((error) { + _errorController.add(error); + if (error.type == MinerErrorType.nodeCrashed && + _state == MiningState.mining) { + _log.w('Node crashed while mining, stopping...'); + _handleCrash(); + } + }); + + // Forward miner errors + _minerErrorSubscription = _minerManager.errors.listen((error) { + _errorController.add(error); + if (error.type == MinerErrorType.minerCrashed && + _state == MiningState.mining) { + _log.w('Miner crashed while mining'); + // Don't stop everything - just emit the error for UI to show + } + }); + } + + void _updateMetricsClient() { + if (_actualMetricsPort != MinerConfig.defaultMinerMetricsPort) { + _minerApiClient = ExternalMinerApiClient( + metricsUrl: MinerConfig.minerMetricsUrl(_actualMetricsPort), + ); + _minerApiClient.onMetricsUpdate = _handleMinerMetrics; + _minerApiClient.onError = _handleMinerMetricsError; + } + } + + Future _readRewardsAddress(File rewardsFile) async { + if (!await rewardsFile.exists()) { + throw Exception('Rewards address file not found: ${rewardsFile.path}'); + } + final address = await rewardsFile.readAsString(); + return address.trim(); + } + + Future _waitForNodeRpc() async { + _log.d('Waiting for node RPC...'); + int attempts = 0; + Duration delay = MinerConfig.rpcInitialRetryDelay; + + while (attempts < MinerConfig.maxRpcRetries) { + try { + final isReady = await _chainRpcClient.isReachable(); + if (isReady) { + _log.i('Node RPC is ready'); + return; + } + } catch (e) { + // Expected during startup + } + + attempts++; + _log.d('RPC not ready (attempt $attempts/${MinerConfig.maxRpcRetries})'); + await Future.delayed(delay); + + if (delay < MinerConfig.rpcMaxRetryDelay) { + delay = Duration(seconds: (delay.inSeconds * 1.5).round()); + if (delay > MinerConfig.rpcMaxRetryDelay) { + delay = MinerConfig.rpcMaxRetryDelay; + } + } + } + + _log.w('Node RPC not ready after max attempts, proceeding anyway'); + } + + void _stopPolling() { + _minerApiClient.stopPolling(); + _chainRpcClient.stopPolling(); + _prometheusTimer?.cancel(); + _prometheusTimer = null; + } + + Future _stopInternal() async { + _stopPolling(); + + // Stop miner first (depends on node) + await _minerManager.stop(); + + // Then stop node + await _nodeManager.stop(); + } + + void _handleCrash() { + _setState(MiningState.error); + _stopPolling(); + } + + void _setState(MiningState newState) { + if (_state != newState) { + _state = newState; + _stateController.add(newState); + _log.d('State changed to: $newState'); + } + } + + void _emitStats() { + _statsController.add(_statsService.currentStats); + } + + void _resetStats() { + _statsService.updateHashrate(0); + _statsService.setMinerRunning(false); + _lastValidHashrate = 0; + _consecutiveMetricsFailures = 0; + _emitStats(); + } + + // ============================================================ + // Metrics Handlers + // ============================================================ + + void _handleMinerMetrics(ExternalMinerMetrics metrics) { + if (metrics.isHealthy && metrics.hashRate > 0) { + _lastValidHashrate = metrics.hashRate; + _consecutiveMetricsFailures = 0; + + _statsService.updateHashrate(metrics.hashRate); + // NOTE: Don't update workers from metrics - miner_workers includes GPU workers + // which would incorrectly inflate the CPU count. We use the configured cpuWorkers instead. + if (metrics.cpuCapacity > 0) { + _statsService.updateCpuCapacity(metrics.cpuCapacity); + } + // NOTE: Don't update gpuDevices from metrics - use the configured value instead + // if (metrics.gpuDevices > 0) { + // _statsService.updateGpuDevices(metrics.gpuDevices); + // } + _emitStats(); + } else if (metrics.hashRate == 0.0 && _lastValidHashrate > 0) { + // Keep last valid hashrate during temporary zeroes + _statsService.updateHashrate(_lastValidHashrate); + _emitStats(); + } else { + _consecutiveMetricsFailures++; + if (_consecutiveMetricsFailures >= + MinerConfig.maxConsecutiveMetricsFailures) { + _statsService.updateHashrate(0); + _lastValidHashrate = 0; + _emitStats(); + } else if (_lastValidHashrate > 0) { + _statsService.updateHashrate(_lastValidHashrate); + _emitStats(); + } + } + } + + void _handleMinerMetricsError(String error) { + _consecutiveMetricsFailures++; + if (_consecutiveMetricsFailures >= + MinerConfig.maxConsecutiveMetricsFailures) { + if (_statsService.currentStats.hashrate != 0) { + _statsService.updateHashrate(0); + _lastValidHashrate = 0; + _emitStats(); + } + } + } + + void _handleChainInfo(ChainInfo info) { + if (info.peerCount >= 0) { + _statsService.updatePeerCount(info.peerCount); + } + _statsService.updateChainName(info.chainName); + _statsService.setSyncingState( + info.isSyncing, + info.currentBlock, + info.targetBlock ?? info.currentBlock, + ); + _emitStats(); + } + + void _handleChainRpcError(String error) { + if (!error.contains('Connection refused') && !error.contains('timeout')) { + _log.w('Chain RPC error: $error'); + } + } + + Future _fetchPrometheusMetrics() async { + try { + final metrics = await _prometheusService.fetchMetrics(); + if (metrics?.targetBlock != null) { + if (_statsService.currentStats.targetBlock < metrics!.targetBlock!) { + _statsService.updateTargetBlock(metrics.targetBlock!); + _emitStats(); + } + } + } catch (e) { + _log.w('Failed to fetch Prometheus metrics', error: e); + } + } +} diff --git a/miner-app/lib/src/services/mining_stats_service.dart b/miner-app/lib/src/services/mining_stats_service.dart index e07f86cc..1786e93b 100644 --- a/miner-app/lib/src/services/mining_stats_service.dart +++ b/miner-app/lib/src/services/mining_stats_service.dart @@ -10,6 +10,7 @@ class MiningStats { final int gpuDevices; final int gpuCapacity; final bool isSyncing; + final bool isMinerRunning; final MiningStatus status; final String chainName; @@ -23,6 +24,7 @@ class MiningStats { this.gpuDevices = 0, this.gpuCapacity = 0, required this.isSyncing, + this.isMinerRunning = false, required this.status, required this.chainName, }); @@ -37,6 +39,7 @@ class MiningStats { gpuDevices = 0, gpuCapacity = 0, isSyncing = false, + isMinerRunning = false, status = MiningStatus.idle, chainName = ''; @@ -50,6 +53,7 @@ class MiningStats { int? gpuDevices, int? gpuCapacity, bool? isSyncing, + bool? isMinerRunning, MiningStatus? status, String? chainName, }) { @@ -63,6 +67,7 @@ class MiningStats { gpuDevices: gpuDevices ?? this.gpuDevices, gpuCapacity: gpuCapacity ?? this.gpuCapacity, isSyncing: isSyncing ?? this.isSyncing, + isMinerRunning: isMinerRunning ?? this.isMinerRunning, status: status ?? this.status, chainName: chainName ?? this.chainName, ); @@ -134,14 +139,34 @@ class MiningStatsService { /// Manually set syncing state (used by RPC client) void setSyncingState(bool isSyncing, int? currentBlock, int? targetBlock) { - final status = isSyncing ? MiningStatus.syncing : MiningStatus.mining; - _currentStats = _currentStats.copyWith( isSyncing: isSyncing, - status: status, currentBlock: currentBlock ?? _currentStats.currentBlock, targetBlock: targetBlock ?? _currentStats.targetBlock, ); + _updateStatus(); + } + + /// Set whether the miner process is running + void setMinerRunning(bool isRunning) { + _currentStats = _currentStats.copyWith(isMinerRunning: isRunning); + _updateStatus(); + } + + /// Update the status based on current state + void _updateStatus() { + MiningStatus status; + if (_currentStats.isSyncing) { + status = MiningStatus.syncing; + } else if (_currentStats.isMinerRunning) { + status = MiningStatus.mining; + } else { + status = MiningStatus.idle; + } + + if (_currentStats.status != status) { + _currentStats = _currentStats.copyWith(status: status); + } } /// Update chain name from RPC data diff --git a/miner-app/lib/src/services/node_process_manager.dart b/miner-app/lib/src/services/node_process_manager.dart new file mode 100644 index 00000000..b8f35f2d --- /dev/null +++ b/miner-app/lib/src/services/node_process_manager.dart @@ -0,0 +1,157 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:quantus_miner/src/config/miner_config.dart'; +import 'package:quantus_miner/src/models/miner_error.dart'; +import 'package:quantus_miner/src/services/base_process_manager.dart'; +import 'package:quantus_miner/src/services/binary_manager.dart'; +import 'package:quantus_miner/src/services/log_stream_processor.dart'; +import 'package:quantus_miner/src/utils/app_logger.dart'; + +final _log = log.withTag('NodeProcess'); + +/// Configuration for starting the node process. +class NodeConfig { + /// Path to the node binary. + final File binary; + + /// Path to the node identity key file. + final File identityFile; + + /// The rewards address for mining. + final String rewardsAddress; + + /// Chain ID to connect to ('dev' or 'dirac'). + final String chainId; + + /// Port for the QUIC miner connection. + final int minerListenPort; + + /// Port for JSON-RPC endpoint. + final int rpcPort; + + /// Port for Prometheus metrics. + final int prometheusPort; + + /// Port for P2P networking. + final int p2pPort; + + NodeConfig({ + required this.binary, + required this.identityFile, + required this.rewardsAddress, + this.chainId = 'dev', + this.minerListenPort = 9833, + this.rpcPort = 9933, + this.prometheusPort = 9616, + this.p2pPort = 30333, + }); +} + +/// Manages the quantus-node process lifecycle. +/// +/// Responsibilities: +/// - Starting the node process with proper arguments +/// - Monitoring process health and exit +/// - Stopping the process gracefully or forcefully +/// - Emitting log entries and error events +class NodeProcessManager extends BaseProcessManager { + /// Callback to get current sync state for log filtering. + SyncStateProvider? getSyncState; + + @override + TaggedLoggerWrapper get log => _log; + + @override + String get processName => 'node'; + + @override + MinerError createStartupError(dynamic error, [StackTrace? stackTrace]) { + return MinerError.nodeStartupFailed(error, stackTrace); + } + + @override + MinerError createCrashError(int exitCode) { + return MinerError.nodeCrashed(exitCode); + } + + NodeProcessManager() { + initLogProcessor('node', getSyncState: () => getSyncState?.call() ?? false); + } + + /// Start the node process. + /// + /// Throws an exception if startup fails. + Future start(NodeConfig config) async { + if (isRunning) { + log.w('Node already running (PID: $pid)'); + return; + } + + intentionalStop = false; + + // Validate binary exists + if (!await config.binary.exists()) { + final error = MinerError.nodeStartupFailed( + 'Node binary not found: ${config.binary.path}', + ); + errorController.add(error); + throw Exception(error.message); + } + + // Validate identity file exists + if (!await config.identityFile.exists()) { + final error = MinerError.nodeStartupFailed( + 'Identity file not found: ${config.identityFile.path}', + ); + errorController.add(error); + throw Exception(error.message); + } + + // Prepare data directory + final quantusHome = await BinaryManager.getQuantusHomeDirectoryPath(); + final basePath = p.join(quantusHome, 'node_data'); + await Directory(basePath).create(recursive: true); + + // Build command arguments + final args = _buildArgs(config, basePath); + + log.i('Starting node...'); + log.d('Command: ${config.binary.path} ${args.join(' ')}'); + + try { + final proc = await Process.start(config.binary.path, args); + attachProcess(proc); + + // Monitor for unexpected exit + proc.exitCode.then(handleExit); + + log.i('Node started (PID: $pid)'); + } catch (e, st) { + final error = MinerError.nodeStartupFailed(e, st); + errorController.add(error); + clearProcess(); + rethrow; + } + } + + List _buildArgs(NodeConfig config, String basePath) { + return [ + // Only use --base-path for non-dev chains (dev uses temp storage for fresh state) + if (config.chainId != 'dev') ...['--base-path', basePath], + '--node-key-file', config.identityFile.path, + '--rewards-address', config.rewardsAddress, + '--validator', + // Chain selection + if (config.chainId == 'dev') '--dev' else ...['--chain', config.chainId], + '--port', config.p2pPort.toString(), + '--prometheus-port', config.prometheusPort.toString(), + '--experimental-rpc-endpoint', + 'listen-addr=${MinerConfig.localhost}:${config.rpcPort},methods=unsafe,cors=all', + '--name', 'QuantusMinerGUI', + '--miner-listen-port', config.minerListenPort.toString(), + '--enable-peer-sharing', + ]; + } +} diff --git a/miner-app/lib/src/services/process_cleanup_service.dart b/miner-app/lib/src/services/process_cleanup_service.dart new file mode 100644 index 00000000..18a8b1a1 --- /dev/null +++ b/miner-app/lib/src/services/process_cleanup_service.dart @@ -0,0 +1,448 @@ +import 'dart:io'; + +import 'package:quantus_miner/src/config/miner_config.dart'; +import 'package:quantus_miner/src/services/binary_manager.dart'; +import 'package:quantus_miner/src/utils/app_logger.dart'; + +final _log = log.withTag('ProcessCleanup'); + +/// Service responsible for platform-specific process management operations. +/// +/// This includes: +/// - Checking if processes are running +/// - Killing processes by PID or name +/// - Port availability checking and cleanup +/// - Database lock file cleanup +/// - Directory access verification +class ProcessCleanupService { + ProcessCleanupService._(); + + // ============================================================ + // Process Running Checks + // ============================================================ + + /// Check if a process with the given PID is currently running. + static Future isProcessRunning(int pid) async { + try { + if (Platform.isWindows) { + final result = await Process.run('tasklist', ['/FI', 'PID eq $pid']); + return result.stdout.toString().contains(' $pid '); + } else { + // On Unix, kill -0 checks if process exists without killing it + final result = await Process.run('kill', ['-0', pid.toString()]); + return result.exitCode == 0; + } + } catch (e) { + return false; + } + } + + // ============================================================ + // Process Killing + // ============================================================ + + /// Force kill a process by PID with verification. + /// + /// Returns true if the process was successfully killed or was already dead. + static Future forceKillProcess(int pid, String processName) async { + try { + _log.d(' Force killing $processName (PID: $pid)'); + + if (Platform.isWindows) { + return await _forceKillWindowsProcess(pid, processName); + } else { + return await _forceKillUnixProcess(pid, processName); + } + } catch (e) { + _log.e('Error killing $processName', error: e); + return false; + } + } + + static Future _forceKillWindowsProcess( + int pid, + String processName, + ) async { + final killResult = await Process.run('taskkill', [ + '/F', + '/PID', + pid.toString(), + ]); + + if (killResult.exitCode == 0) { + _log.d('Killed $processName (PID: $pid)'); + } else { + _log.w( + 'taskkill failed for $processName (PID: $pid), exit: ${killResult.exitCode}', + ); + } + + await Future.delayed(MinerConfig.processVerificationDelay); + + // Verify process is dead + final checkResult = await Process.run('tasklist', ['/FI', 'PID eq $pid']); + if (checkResult.stdout.toString().contains(' $pid ')) { + _log.w('$processName (PID: $pid) may still be running'); + + // Try by name as last resort + final binaryName = processName.contains('miner') + ? MinerConfig.minerBinaryNameWindows + : MinerConfig.nodeBinaryNameWindows; + await Process.run('taskkill', ['/F', '/IM', binaryName]); + return false; + } + + _log.d('Verified $processName (PID: $pid) terminated'); + return true; + } + + static Future _forceKillUnixProcess(int pid, String processName) async { + // First try SIGKILL via kill command + final killResult = await Process.run('kill', ['-9', pid.toString()]); + + if (killResult.exitCode == 0) { + _log.d('Killed $processName (PID: $pid)'); + } else { + _log.w( + 'kill failed for $processName (PID: $pid), exit: ${killResult.exitCode}', + ); + } + + await Future.delayed(MinerConfig.processVerificationDelay); + + // Verify process is dead + final checkResult = await Process.run('kill', ['-0', pid.toString()]); + if (checkResult.exitCode == 0) { + _log.w('$processName (PID: $pid) may still be running'); + + // Try pkill as last resort + final binaryName = processName.contains('miner') + ? MinerConfig.minerBinaryName + : MinerConfig.nodeBinaryName; + await Process.run('pkill', ['-9', '-f', binaryName]); + return false; + } + + _log.d('Verified $processName (PID: $pid) terminated'); + return true; + } + + /// Kill all processes matching the given binary name. + static Future killProcessesByName(String binaryName) async { + try { + if (Platform.isWindows) { + await Process.run('taskkill', ['/F', '/IM', '$binaryName.exe']); + } else { + await Process.run('pkill', ['-9', '-f', binaryName]); + } + } catch (e) { + // Ignore errors - processes might not exist + } + } + + // ============================================================ + // Port Management + // ============================================================ + + /// Check if a port is currently in use. + static Future isPortInUse(int port) async { + try { + if (Platform.isWindows) { + final result = await Process.run('netstat', ['-ano']); + return result.exitCode == 0 && + result.stdout.toString().contains(':$port'); + } else { + final result = await Process.run('lsof', ['-i', ':$port']); + return result.exitCode == 0 && result.stdout.toString().isNotEmpty; + } + } catch (e) { + // lsof might not be available, try netstat as fallback + try { + final result = await Process.run('netstat', ['-an']); + return result.stdout.toString().contains(':$port'); + } catch (e2) { + _log.d('Could not check port $port availability'); + return false; + } + } + } + + /// Kill any process using the specified port. + static Future killProcessOnPort(int port) async { + try { + if (Platform.isWindows) { + await _killProcessOnPortWindows(port); + } else { + await _killProcessOnPortUnix(port); + } + } catch (e) { + // Ignore cleanup errors + } + } + + static Future _killProcessOnPortWindows(int port) async { + final result = await Process.run('netstat', ['-ano']); + if (result.exitCode != 0) return; + + final lines = result.stdout.toString().split('\n'); + for (final line in lines) { + if (line.contains(':$port')) { + final parts = line.trim().split(RegExp(r'\s+')); + if (parts.isNotEmpty) { + final pid = parts.last; + // Verify it's a valid PID number + if (int.tryParse(pid) != null) { + await Process.run('taskkill', ['/F', '/PID', pid]); + } + } + } + } + } + + static Future _killProcessOnPortUnix(int port) async { + final result = await Process.run('lsof', ['-ti', ':$port']); + if (result.exitCode != 0) return; + + final pids = result.stdout.toString().trim().split('\n'); + for (final pid in pids) { + if (pid.isNotEmpty) { + await Process.run('kill', ['-9', pid.trim()]); + } + } + } + + /// Find an available port starting from the given port. + /// + /// Tries ports in range [startPort, startPort + MinerConfig.portSearchRange]. + /// Returns the original port if no alternative is found. + static Future findAvailablePort(int startPort) async { + for ( + int port = startPort; + port <= startPort + MinerConfig.portSearchRange; + port++ + ) { + if (!(await isPortInUse(port))) { + return port; + } + } + return startPort; // Return original if no alternative found + } + + /// Ensure required ports are available, cleaning up if necessary. + /// + /// Returns a map of port names to their actual values (may differ from defaults + /// if an alternative port was needed). + static Future> ensurePortsAvailable({ + required int quicPort, + required int metricsPort, + }) async { + final result = {'quic': quicPort, 'metrics': metricsPort}; + + // Check QUIC port + if (await isPortInUse(quicPort)) { + await killProcessOnPort(quicPort); + await Future.delayed(MinerConfig.portCleanupDelay); + + if (await isPortInUse(quicPort)) { + throw Exception('Port $quicPort is still in use after cleanup attempt'); + } + } + + // Check metrics port + if (await isPortInUse(metricsPort)) { + await killProcessOnPort(metricsPort); + await Future.delayed(MinerConfig.portCleanupDelay); + + if (await isPortInUse(metricsPort)) { + // Try to find an alternative port + final altPort = await findAvailablePort(metricsPort + 1); + _log.i('Using alternative metrics port: $altPort'); + result['metrics'] = altPort; + } + } + + return result; + } + + // ============================================================ + // Existing Process Cleanup + // ============================================================ + + /// Cleanup any existing quantus-node processes. + static Future cleanupExistingNodeProcesses() async { + try { + if (Platform.isWindows) { + await Process.run('taskkill', [ + '/F', + '/IM', + MinerConfig.nodeBinaryNameWindows, + ]); + await Future.delayed(MinerConfig.processCleanupDelay); + } else { + await _cleanupUnixProcesses(MinerConfig.nodeBinaryName); + } + } catch (e) { + // Ignore cleanup errors + } + } + + /// Cleanup any existing quantus-miner processes. + static Future cleanupExistingMinerProcesses() async { + try { + if (Platform.isWindows) { + await Process.run('taskkill', [ + '/F', + '/IM', + MinerConfig.minerBinaryNameWindows, + ]); + await Future.delayed(MinerConfig.processCleanupDelay); + } else { + await _cleanupUnixProcesses(MinerConfig.minerBinaryName); + } + } catch (e) { + // Ignore cleanup errors + } + } + + static Future _cleanupUnixProcesses(String processName) async { + final result = await Process.run('pgrep', ['-f', processName]); + if (result.exitCode != 0) return; + + final pids = result.stdout.toString().trim().split('\n'); + for (final pid in pids) { + if (pid.isEmpty) continue; + + try { + // Try graceful termination first (SIGTERM) + await Process.run('kill', ['-15', pid.trim()]); + await Future.delayed(const Duration(seconds: 1)); + + // Check if still running, force kill if needed + final checkResult = await Process.run('kill', ['-0', pid.trim()]); + if (checkResult.exitCode == 0) { + await Process.run('kill', ['-9', pid.trim()]); + } + } catch (e) { + // Ignore cleanup errors + } + } + + // Wait for processes to fully terminate + await Future.delayed(MinerConfig.processCleanupDelay); + } + + // ============================================================ + // Database & Directory Cleanup + // ============================================================ + + /// Cleanup database lock files that may prevent node startup. + static Future cleanupDatabaseLocks(String chainId) async { + try { + final quantusHome = await BinaryManager.getQuantusHomeDirectoryPath(); + final lockFilePath = + '$quantusHome/node_data/chains/$chainId/db/full/LOCK'; + final lockFile = File(lockFilePath); + + if (await lockFile.exists()) { + await lockFile.delete(); + _log.d(' Deleted lock file: $lockFilePath'); + } + + // Also check for other potential lock files + final dbDir = Directory('$quantusHome/node_data/chains/$chainId/db/full'); + if (await dbDir.exists()) { + await for (final entity in dbDir.list()) { + if (entity is File && entity.path.contains('LOCK')) { + try { + await entity.delete(); + _log.d(' Deleted lock file: ${entity.path}'); + } catch (e) { + // Ignore cleanup errors + } + } + } + } + } catch (e) { + // Ignore cleanup errors + } + } + + /// Check and fix database directory permissions. + static Future ensureDatabaseDirectoryAccess(String chainId) async { + try { + final quantusHome = await BinaryManager.getQuantusHomeDirectoryPath(); + final dbPath = '$quantusHome/node_data/chains/$chainId/db'; + final dbDir = Directory(dbPath); + + // Create the directory if it doesn't exist + if (!await dbDir.exists()) { + await dbDir.create(recursive: true); + } + + // Check if directory is writable + final testFile = File('$dbPath/test_write_access'); + try { + await testFile.writeAsString('test'); + await testFile.delete(); + } catch (e) { + // Try to fix permissions (Unix only) + if (!Platform.isWindows) { + try { + await Process.run('chmod', ['-R', '755', dbPath]); + } catch (permError) { + // Ignore permission fix errors + } + } + } + } catch (e) { + // Ignore directory access errors + } + } + + // ============================================================ + // Combined Cleanup Operations + // ============================================================ + + /// Perform full cleanup before starting mining. + /// + /// This cleans up: + /// - Existing node processes + /// - Existing miner processes + /// - Database locks + /// - Ensures directory access + static Future performPreStartCleanup(String chainId) async { + await cleanupExistingNodeProcesses(); + await cleanupExistingMinerProcesses(); + await cleanupDatabaseLocks(chainId); + await ensureDatabaseDirectoryAccess(chainId); + } + + /// Kill all quantus processes by name. + /// + /// This is a more aggressive cleanup used during app exit. + static Future killAllQuantusProcesses() async { + try { + _log.d(' Killing all quantus processes...'); + + if (Platform.isWindows) { + await Process.run('taskkill', [ + '/F', + '/IM', + MinerConfig.nodeBinaryNameWindows, + ]); + await Process.run('taskkill', [ + '/F', + '/IM', + MinerConfig.minerBinaryNameWindows, + ]); + } else { + await Process.run('pkill', ['-9', '-f', MinerConfig.nodeBinaryName]); + await Process.run('pkill', ['-9', '-f', MinerConfig.minerBinaryName]); + } + + _log.d(' Cleanup commands executed'); + } catch (e) { + _log.d(' Error killing processes: $e'); + } + } +} diff --git a/miner-app/lib/src/services/prometheus_service.dart b/miner-app/lib/src/services/prometheus_service.dart index ab5629c2..46540403 100644 --- a/miner-app/lib/src/services/prometheus_service.dart +++ b/miner-app/lib/src/services/prometheus_service.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:http/http.dart' as http; +import 'package:quantus_miner/src/config/miner_config.dart'; // Data class to hold the parsed metrics class PrometheusMetrics { @@ -8,7 +9,12 @@ class PrometheusMetrics { final int? targetBlock; final int? peerCount; - PrometheusMetrics({required this.isMajorSyncing, this.bestBlock, this.targetBlock, this.peerCount}); + PrometheusMetrics({ + required this.isMajorSyncing, + this.bestBlock, + this.targetBlock, + this.peerCount, + }); @override String toString() { @@ -19,11 +25,16 @@ class PrometheusMetrics { class PrometheusService { final String metricsUrl; - PrometheusService({this.metricsUrl = 'http://127.0.0.1:9616/metrics'}); + PrometheusService({String? metricsUrl}) + : metricsUrl = + metricsUrl ?? + MinerConfig.nodePrometheusUrl(MinerConfig.defaultNodePrometheusPort); Future fetchMetrics() async { try { - final response = await http.get(Uri.parse(metricsUrl)).timeout(const Duration(seconds: 3)); + final response = await http + .get(Uri.parse(metricsUrl)) + .timeout(const Duration(seconds: 3)); if (response.statusCode == 200) { final lines = response.body.split('\n'); @@ -44,13 +55,17 @@ class PrometheusService { if (parts.length == 2) { bestBlock = int.tryParse(parts[1]); } - } else if (line.startsWith('substrate_block_height{status="sync_target"')) { + } else if (line.startsWith( + 'substrate_block_height{status="sync_target"', + )) { final parts = line.split(' '); if (parts.length == 2) { targetBlock = int.tryParse(parts[1]); } } else if (line.startsWith('substrate_sub_libp2p_peers_count ') || - line.startsWith('substrate_sub_libp2p_kademlia_query_duration_count ') || + line.startsWith( + 'substrate_sub_libp2p_kademlia_query_duration_count ', + ) || line.contains('substrate_sub_libp2p_connections_opened_total') || line.contains('substrate_peerset_num_discovered_peers')) { // Try various peer-related metrics @@ -69,7 +84,9 @@ class PrometheusService { if (bestBlock != null && targetBlock != null && (targetBlock - bestBlock) > 5 && - !lines.any((l) => l.startsWith('substrate_sub_libp2p_is_major_syncing'))) { + !lines.any( + (l) => l.startsWith('substrate_sub_libp2p_is_major_syncing'), + )) { // If the specific major sync metric isn't there, but there's a clear block difference, // infer syncing state. isSyncing = true; diff --git a/miner-app/lib/src/shared/extensions/snackbar_extensions.dart b/miner-app/lib/src/shared/extensions/snackbar_extensions.dart index 28667de2..faaf24b3 100644 --- a/miner-app/lib/src/shared/extensions/snackbar_extensions.dart +++ b/miner-app/lib/src/shared/extensions/snackbar_extensions.dart @@ -13,11 +13,17 @@ extension SnackbarExtensions on BuildContext { await sh.showCopySnackbar(this, title: title, message: message); } - Future showWarningSnackbar({required String title, required String message}) async { + Future showWarningSnackbar({ + required String title, + required String message, + }) async { await sh.showWarningSnackbar(this, title: title, message: message); } - Future showErrorSnackbar({required String title, required String message}) async { + Future showErrorSnackbar({ + required String title, + required String message, + }) async { await sh.showErrorSnackbar(this, title: title, message: message); } } diff --git a/miner-app/lib/src/ui/logs_widget.dart b/miner-app/lib/src/ui/logs_widget.dart index f86ed0d3..49322d5d 100644 --- a/miner-app/lib/src/ui/logs_widget.dart +++ b/miner-app/lib/src/ui/logs_widget.dart @@ -3,13 +3,14 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; -import '../services/miner_process.dart'; +import '../services/log_stream_processor.dart'; +import '../services/mining_orchestrator.dart'; class LogsWidget extends StatefulWidget { - final MinerProcess? minerProcess; + final MiningOrchestrator? orchestrator; final int maxLines; - const LogsWidget({super.key, this.minerProcess, this.maxLines = 20000}); + const LogsWidget({super.key, this.orchestrator, this.maxLines = 20000}); @override State createState() => _LogsWidgetState(); @@ -30,7 +31,7 @@ class _LogsWidgetState extends State { @override void didUpdateWidget(LogsWidget oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.minerProcess != widget.minerProcess) { + if (oldWidget.orchestrator != widget.orchestrator) { _setupLogsListener(); } } @@ -39,8 +40,8 @@ class _LogsWidgetState extends State { _logsSubscription?.cancel(); _logs.clear(); - if (widget.minerProcess != null) { - _logsSubscription = widget.minerProcess!.logsStream.listen((logEntry) { + if (widget.orchestrator != null) { + _logsSubscription = widget.orchestrator!.logsStream.listen((logEntry) { if (mounted) { setState(() { _logs.add(logEntry); @@ -89,6 +90,11 @@ class _LogsWidgetState extends State { return Colors.blue; case 'node-error': return Colors.red; + case 'miner': + return Colors.green; + case 'miner-error': + return Colors.orange; + // Legacy source names for compatibility case 'quantus-miner': return Colors.green; case 'quantus-miner-error': @@ -108,18 +114,35 @@ class _LogsWidgetState extends State { padding: const EdgeInsets.all(8.0), decoration: BoxDecoration( color: Theme.of(context).primaryColor.useOpacity(0.1), - borderRadius: const BorderRadius.only(topLeft: Radius.circular(12), topRight: Radius.circular(12)), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(12), + topRight: Radius.circular(12), + ), ), child: Row( children: [ - const Text('Live Logs', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const Text( + 'Live Logs', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), const Spacer(), IconButton( - icon: Icon(_autoScroll ? Icons.vertical_align_bottom : Icons.vertical_align_top, size: 20), + icon: Icon( + _autoScroll + ? Icons.vertical_align_bottom + : Icons.vertical_align_top, + size: 20, + ), onPressed: _toggleAutoScroll, - tooltip: _autoScroll ? 'Disable auto-scroll' : 'Enable auto-scroll', + tooltip: _autoScroll + ? 'Disable auto-scroll' + : 'Enable auto-scroll', + ), + IconButton( + icon: const Icon(Icons.clear, size: 20), + onPressed: _clearLogs, + tooltip: 'Clear logs', ), - IconButton(icon: const Icon(Icons.clear, size: 20), onPressed: _clearLogs, tooltip: 'Clear logs'), ], ), ), @@ -131,9 +154,12 @@ class _LogsWidgetState extends State { child: _logs.isEmpty ? const Center( child: Text( - 'No logs available\nStart mining to see live logs', + 'No logs available\nStart the node to see live logs', textAlign: TextAlign.center, - style: TextStyle(color: Colors.grey, fontStyle: FontStyle.italic), + style: TextStyle( + color: Colors.grey, + fontStyle: FontStyle.italic, + ), ), ) : ListView.builder( @@ -150,8 +176,15 @@ class _LogsWidgetState extends State { SizedBox( width: 80, child: Text( - log.timestamp.toIso8601String().substring(11, 19), - style: TextStyle(fontSize: 12, color: Colors.grey[600], fontFamily: 'monospace'), + log.timestamp.toIso8601String().substring( + 11, + 19, + ), + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + fontFamily: 'monospace', + ), ), ), @@ -160,7 +193,10 @@ class _LogsWidgetState extends State { width: 12, height: 12, margin: const EdgeInsets.only(right: 8, top: 2), - decoration: BoxDecoration(color: _getLogColor(log.source), shape: BoxShape.circle), + decoration: BoxDecoration( + color: _getLogColor(log.source), + shape: BoxShape.circle, + ), ), // Source label @@ -180,7 +216,11 @@ class _LogsWidgetState extends State { Expanded( child: SelectableText( log.message, - style: const TextStyle(fontSize: 12, fontFamily: 'monospace', height: 1.2), + style: const TextStyle( + fontSize: 12, + fontFamily: 'monospace', + height: 1.2, + ), ), ), ], @@ -196,19 +236,32 @@ class _LogsWidgetState extends State { padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), decoration: BoxDecoration( color: Theme.of(context).primaryColor.useOpacity(0.05), - borderRadius: const BorderRadius.only(bottomLeft: Radius.circular(12), bottomRight: Radius.circular(12)), + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(12), + bottomRight: Radius.circular(12), + ), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text('Total logs: ${_logs.length}', style: TextStyle(fontSize: 12, color: Colors.grey[600])), - if (widget.minerProcess != null) + Text( + 'Total logs: ${_logs.length}', + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + if (widget.orchestrator?.isMining ?? false) Text( 'Live', - style: TextStyle(fontSize: 12, color: Colors.green, fontWeight: FontWeight.w500), + style: TextStyle( + fontSize: 12, + color: Colors.green, + fontWeight: FontWeight.w500, + ), ) else - Text('Not connected', style: TextStyle(fontSize: 12, color: Colors.grey)), + Text( + 'Not connected', + style: TextStyle(fontSize: 12, color: Colors.grey), + ), ], ), ), diff --git a/miner-app/lib/src/ui/snackbar_helper.dart b/miner-app/lib/src/ui/snackbar_helper.dart index fa971978..942355b1 100644 --- a/miner-app/lib/src/ui/snackbar_helper.dart +++ b/miner-app/lib/src/ui/snackbar_helper.dart @@ -41,11 +41,19 @@ Future showTopSnackBar( ); } -Future showCopySnackbar(BuildContext context, {required String title, required String message}) async { +Future showCopySnackbar( + BuildContext context, { + required String title, + required String message, +}) async { await showTopSnackBar(context, title: title, message: message); } -Future showWarningSnackbar(BuildContext context, {required String title, required String message}) async { +Future showWarningSnackbar( + BuildContext context, { + required String title, + required String message, +}) async { await showTopSnackBar( context, title: title, @@ -54,7 +62,11 @@ Future showWarningSnackbar(BuildContext context, {required String title, r ); } -Future showErrorSnackbar(BuildContext context, {required String title, required String message}) async { +Future showErrorSnackbar( + BuildContext context, { + required String title, + required String message, +}) async { await showTopSnackBar( context, title: title, diff --git a/miner-app/lib/src/ui/top_snackbar_content.dart b/miner-app/lib/src/ui/top_snackbar_content.dart index 98510740..fa9a4e8e 100644 --- a/miner-app/lib/src/ui/top_snackbar_content.dart +++ b/miner-app/lib/src/ui/top_snackbar_content.dart @@ -7,7 +7,12 @@ class TopSnackBarContent extends StatelessWidget { final String message; final Icon? icon; - const TopSnackBarContent({super.key, required this.title, required this.message, this.icon}); + const TopSnackBarContent({ + super.key, + required this.title, + required this.message, + this.icon, + }); @override Widget build(BuildContext context) { @@ -20,7 +25,11 @@ class TopSnackBarContent extends StatelessWidget { shape: OvalBorder(), // Use OvalBorder for circle ), alignment: Alignment.center, - child: Icon(icon?.icon ?? Icons.check, color: icon?.color ?? Colors.white, size: 24), // Default check icon + child: Icon( + icon?.icon ?? Icons.check, + color: icon?.color ?? Colors.white, + size: 24, + ), // Default check icon ); return Container( @@ -33,7 +42,9 @@ class TopSnackBarContent extends StatelessWidget { side: BorderSide(color: Colors.white.useOpacity(0.1), width: 1), ), // Optional shadow for better visibility - shadows: const [BoxShadow(color: Colors.black26, blurRadius: 4, offset: Offset(0, 2))], + shadows: const [ + BoxShadow(color: Colors.black26, blurRadius: 4, offset: Offset(0, 2)), + ], ), child: Row( mainAxisAlignment: MainAxisAlignment.start, diff --git a/miner-app/lib/src/ui/update_banner.dart b/miner-app/lib/src/ui/update_banner.dart index 3db69c67..837ac867 100644 --- a/miner-app/lib/src/ui/update_banner.dart +++ b/miner-app/lib/src/ui/update_banner.dart @@ -29,7 +29,13 @@ class UpdateBanner extends StatelessWidget { width: double.infinity, decoration: BoxDecoration( color: backgroundColor ?? Colors.blue.shade500, - boxShadow: [BoxShadow(color: Colors.black.useOpacity(0.1), blurRadius: 4, offset: const Offset(0, 2))], + boxShadow: [ + BoxShadow( + color: Colors.black.useOpacity(0.1), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], ), child: SafeArea( bottom: false, @@ -37,7 +43,11 @@ class UpdateBanner extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ - Icon(icon ?? Icons.download, color: textColor ?? Colors.white, size: 24), + Icon( + icon ?? Icons.download, + color: textColor ?? Colors.white, + size: 24, + ), const SizedBox(width: 12), Expanded( child: Column( @@ -46,35 +56,57 @@ class UpdateBanner extends StatelessWidget { children: [ Text( message, - style: TextStyle(color: textColor ?? Colors.white, fontSize: 14, fontWeight: FontWeight.w600), + style: TextStyle( + color: textColor ?? Colors.white, + fontSize: 14, + fontWeight: FontWeight.w600, + ), ), const SizedBox(height: 2), Text( 'Version $version', - style: TextStyle(color: (textColor ?? Colors.white).useOpacity(0.9), fontSize: 12), + style: TextStyle( + color: (textColor ?? Colors.white).useOpacity(0.9), + fontSize: 12, + ), ), ], ), ), const SizedBox(width: 8), if (updateProgress != null) - SizedBox(width: 100, child: LinearProgressIndicator(value: updateProgress)) + SizedBox( + width: 100, + child: LinearProgressIndicator(value: updateProgress), + ) else ElevatedButton( onPressed: onUpdate, style: ElevatedButton.styleFrom( backgroundColor: Colors.white, foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + child: const Text( + 'Update', + style: TextStyle(fontWeight: FontWeight.bold), ), - child: const Text('Update', style: TextStyle(fontWeight: FontWeight.bold)), ), if (onDismiss != null && updateProgress == null) ...[ const SizedBox(width: 8), IconButton( onPressed: onDismiss, - icon: Icon(Icons.close, color: textColor ?? Colors.white, size: 20), + icon: Icon( + Icons.close, + color: textColor ?? Colors.white, + size: 20, + ), padding: EdgeInsets.zero, constraints: const BoxConstraints(), ), diff --git a/miner-app/lib/src/utils/app_logger.dart b/miner-app/lib/src/utils/app_logger.dart new file mode 100644 index 00000000..54c1bc3b --- /dev/null +++ b/miner-app/lib/src/utils/app_logger.dart @@ -0,0 +1,192 @@ +import 'package:flutter/foundation.dart'; +import 'package:logger/logger.dart'; + +/// Application-wide logger instance. +/// +/// Usage: +/// ```dart +/// import 'package:quantus_miner/src/utils/app_logger.dart'; +/// +/// log.d('Debug message'); +/// log.i('Info message'); +/// log.w('Warning message'); +/// log.e('Error message', error: e, stackTrace: st); +/// ``` +/// +/// Log levels: +/// - `d` (debug): Detailed debugging information +/// - `i` (info): General information about app operation +/// - `w` (warning): Potential issues that don't prevent operation +/// - `e` (error): Errors that affect functionality +final Logger log = Logger( + // In release mode, only show warnings and errors + // In debug mode, show all logs + level: kReleaseMode ? Level.warning : Level.all, + printer: _AppLogPrinter(), + // No file output for now + output: null, +); + +/// Custom log printer for cleaner console output. +/// +/// Format: `[LEVEL] [SOURCE] message` +/// Example: `[D] [MinerProcess] Starting node...` +class _AppLogPrinter extends LogPrinter { + static final _levelPrefixes = { + Level.trace: 'T', + Level.debug: 'D', + Level.info: 'I', + Level.warning: 'W', + Level.error: 'E', + Level.fatal: 'F', + }; + + static final _levelColors = { + Level.trace: AnsiColor.fg(AnsiColor.grey(0.5)), + Level.debug: AnsiColor.none(), + Level.info: AnsiColor.fg(12), // Light blue + Level.warning: AnsiColor.fg(208), // Orange + Level.error: AnsiColor.fg(196), // Red + Level.fatal: AnsiColor.fg(199), // Magenta + }; + + @override + List log(LogEvent event) { + final prefix = _levelPrefixes[event.level] ?? '?'; + final color = _levelColors[event.level] ?? AnsiColor.none(); + final time = _formatTime(event.time); + + final messageStr = event.message.toString(); + final lines = []; + + // Main log line + lines.add(color('[$time] [$prefix] $messageStr')); + + // Add error if present + if (event.error != null) { + lines.add(color('[$time] [$prefix] Error: ${event.error}')); + } + + // Add stack trace if present (only for errors) + if (event.stackTrace != null && event.level.index >= Level.error.index) { + final stackLines = event.stackTrace.toString().split('\n').take(5); + for (final line in stackLines) { + if (line.trim().isNotEmpty) { + lines.add(color('[$time] [$prefix] $line')); + } + } + } + + return lines; + } + + String _formatTime(DateTime time) { + return '${time.hour.toString().padLeft(2, '0')}:' + '${time.minute.toString().padLeft(2, '0')}:' + '${time.second.toString().padLeft(2, '0')}'; + } +} + +/// Extension to make logging from specific sources cleaner. +/// +/// Usage: +/// ```dart +/// final _log = log.withTag('MinerProcess'); +/// _log.d('Starting...'); // Output: [D] [MinerProcess] Starting... +/// ``` +extension TaggedLogger on Logger { + /// Create a logger that prefixes all messages with a tag. + TaggedLoggerWrapper withTag(String tag) => TaggedLoggerWrapper(this, tag); +} + +/// Wrapper that adds a tag prefix to all log messages. +class TaggedLoggerWrapper { + final Logger _logger; + final String _tag; + + TaggedLoggerWrapper(this._logger, this._tag); + + void t( + dynamic message, { + DateTime? time, + Object? error, + StackTrace? stackTrace, + }) { + _logger.t( + '[$_tag] $message', + time: time, + error: error, + stackTrace: stackTrace, + ); + } + + void d( + dynamic message, { + DateTime? time, + Object? error, + StackTrace? stackTrace, + }) { + _logger.d( + '[$_tag] $message', + time: time, + error: error, + stackTrace: stackTrace, + ); + } + + void i( + dynamic message, { + DateTime? time, + Object? error, + StackTrace? stackTrace, + }) { + _logger.i( + '[$_tag] $message', + time: time, + error: error, + stackTrace: stackTrace, + ); + } + + void w( + dynamic message, { + DateTime? time, + Object? error, + StackTrace? stackTrace, + }) { + _logger.w( + '[$_tag] $message', + time: time, + error: error, + stackTrace: stackTrace, + ); + } + + void e( + dynamic message, { + DateTime? time, + Object? error, + StackTrace? stackTrace, + }) { + _logger.e( + '[$_tag] $message', + time: time, + error: error, + stackTrace: stackTrace, + ); + } + + void f( + dynamic message, { + DateTime? time, + Object? error, + StackTrace? stackTrace, + }) { + _logger.f( + '[$_tag] $message', + time: time, + error: error, + stackTrace: stackTrace, + ); + } +} diff --git a/miner-app/pubspec.yaml b/miner-app/pubspec.yaml index c3d1e6fb..f23926b3 100644 --- a/miner-app/pubspec.yaml +++ b/miner-app/pubspec.yaml @@ -18,15 +18,12 @@ dependencies: # Networking and storage http: # Version managed by melos.yaml shared_preferences: # Version managed by melos.yaml + polkadart: # For local node RPC queries - # State management and architecture - provider: # Version managed by melos.yaml - hooks_riverpod: # Version managed by melos.yaml - flutter_hooks: # Version managed by melos.yaml + # Routing go_router: # Version managed by melos.yaml # UI and utilities - another_flushbar: # Version managed by melos.yaml flutter_svg: # Version managed by melos.yaml # System and file operations