From 067f1bc2f837ba545d78c82dd9207ea0a9030ccf Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Mon, 20 Apr 2026 23:31:05 +0200 Subject: [PATCH] Add xmrig service layer to peyawallet --- lib/l10n/app_en.arb | 9 + lib/l10n/app_localizations.dart | 54 ++++ lib/l10n/app_localizations_en.dart | 27 ++ lib/l10n/app_localizations_pl.dart | 27 ++ lib/l10n/app_pl.arb | 9 + lib/services/app_paths.dart | 12 + lib/services/bundled_mining_service.dart | 324 +++++++++++++++++++ lib/services/mining_service.dart | 59 ++++ lib/services/noop_mining_service.dart | 28 ++ lib/state/mining_controller.dart | 216 +++++++++++++ lib/state/providers.dart | 22 ++ lib/ui/screens/mining_screen.dart | 378 ++++++++++++++--------- 12 files changed, 1024 insertions(+), 141 deletions(-) create mode 100644 lib/services/bundled_mining_service.dart create mode 100644 lib/services/mining_service.dart create mode 100644 lib/services/noop_mining_service.dart create mode 100644 lib/state/mining_controller.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 153d185..ea3f57c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -362,11 +362,20 @@ "miningControlsTitle": "Controls", "miningBackendPending": "Miner process control will be enabled in the next step. This screen already stores the target config and release layout.", "miningStatsTitle": "Stats", + "miningStatusRunning": "Miner is running.", + "miningStatusStopped": "Miner is stopped.", "miningHashrate10sLabel": "Hashrate (10s)", "miningHashrate1mLabel": "Hashrate (1m)", "miningJobsLabel": "Jobs", "miningAcceptedSharesLabel": "Accepted shares", "miningRejectedSharesLabel": "Rejected shares", + "miningCurrentDiffLabel": "Current difficulty", + "miningMinerStartSuccess": "Miner started", + "miningMinerStartFailure": "Failed to start miner", + "miningMinerStopSuccess": "Miner stopped", + "miningMinerStopFailure": "Failed to stop miner", + "miningMinerRestartSuccess": "Miner restarted", + "miningMinerRestartFailure": "Failed to restart miner", "miningBundledTitle": "Bundled miner", "miningBundledEnabled": "This build includes the bundled XMRig binary.", "miningBundledDisabled": "This release flavor does not include the bundled miner.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 413f62b..af5cbe6 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1796,6 +1796,18 @@ abstract class AppLocalizations { /// **'Stats'** String get miningStatsTitle; + /// No description provided for @miningStatusRunning. + /// + /// In en, this message translates to: + /// **'Miner is running.'** + String get miningStatusRunning; + + /// No description provided for @miningStatusStopped. + /// + /// In en, this message translates to: + /// **'Miner is stopped.'** + String get miningStatusStopped; + /// No description provided for @miningHashrate10sLabel. /// /// In en, this message translates to: @@ -1826,6 +1838,48 @@ abstract class AppLocalizations { /// **'Rejected shares'** String get miningRejectedSharesLabel; + /// No description provided for @miningCurrentDiffLabel. + /// + /// In en, this message translates to: + /// **'Current difficulty'** + String get miningCurrentDiffLabel; + + /// No description provided for @miningMinerStartSuccess. + /// + /// In en, this message translates to: + /// **'Miner started'** + String get miningMinerStartSuccess; + + /// No description provided for @miningMinerStartFailure. + /// + /// In en, this message translates to: + /// **'Failed to start miner'** + String get miningMinerStartFailure; + + /// No description provided for @miningMinerStopSuccess. + /// + /// In en, this message translates to: + /// **'Miner stopped'** + String get miningMinerStopSuccess; + + /// No description provided for @miningMinerStopFailure. + /// + /// In en, this message translates to: + /// **'Failed to stop miner'** + String get miningMinerStopFailure; + + /// No description provided for @miningMinerRestartSuccess. + /// + /// In en, this message translates to: + /// **'Miner restarted'** + String get miningMinerRestartSuccess; + + /// No description provided for @miningMinerRestartFailure. + /// + /// In en, this message translates to: + /// **'Failed to restart miner'** + String get miningMinerRestartFailure; + /// No description provided for @miningBundledTitle. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 6b7749e..57e1452 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -907,6 +907,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get miningStatsTitle => 'Stats'; + @override + String get miningStatusRunning => 'Miner is running.'; + + @override + String get miningStatusStopped => 'Miner is stopped.'; + @override String get miningHashrate10sLabel => 'Hashrate (10s)'; @@ -922,6 +928,27 @@ class AppLocalizationsEn extends AppLocalizations { @override String get miningRejectedSharesLabel => 'Rejected shares'; + @override + String get miningCurrentDiffLabel => 'Current difficulty'; + + @override + String get miningMinerStartSuccess => 'Miner started'; + + @override + String get miningMinerStartFailure => 'Failed to start miner'; + + @override + String get miningMinerStopSuccess => 'Miner stopped'; + + @override + String get miningMinerStopFailure => 'Failed to stop miner'; + + @override + String get miningMinerRestartSuccess => 'Miner restarted'; + + @override + String get miningMinerRestartFailure => 'Failed to restart miner'; + @override String get miningBundledTitle => 'Bundled miner'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index ba84302..c2efe58 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -911,6 +911,12 @@ class AppLocalizationsPl extends AppLocalizations { @override String get miningStatsTitle => 'Statystyki'; + @override + String get miningStatusRunning => 'Miner działa.'; + + @override + String get miningStatusStopped => 'Miner jest zatrzymany.'; + @override String get miningHashrate10sLabel => 'Hashrate (10s)'; @@ -926,6 +932,27 @@ class AppLocalizationsPl extends AppLocalizations { @override String get miningRejectedSharesLabel => 'Odrzucone udziały'; + @override + String get miningCurrentDiffLabel => 'Aktualna trudność'; + + @override + String get miningMinerStartSuccess => 'Miner uruchomiony'; + + @override + String get miningMinerStartFailure => 'Nie udało się uruchomić minera'; + + @override + String get miningMinerStopSuccess => 'Miner zatrzymany'; + + @override + String get miningMinerStopFailure => 'Nie udało się zatrzymać minera'; + + @override + String get miningMinerRestartSuccess => 'Miner zrestartowany'; + + @override + String get miningMinerRestartFailure => 'Nie udało się zrestartować minera'; + @override String get miningBundledTitle => 'Bundlowany miner'; diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 1a13ef7..311c250 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -362,11 +362,20 @@ "miningControlsTitle": "Sterowanie", "miningBackendPending": "Sterowanie procesem minera zostanie dopięte w następnym kroku. Ten ekran zapisuje już docelową konfigurację i układ releasu.", "miningStatsTitle": "Statystyki", + "miningStatusRunning": "Miner działa.", + "miningStatusStopped": "Miner jest zatrzymany.", "miningHashrate10sLabel": "Hashrate (10s)", "miningHashrate1mLabel": "Hashrate (1m)", "miningJobsLabel": "Joby", "miningAcceptedSharesLabel": "Zaakceptowane udziały", "miningRejectedSharesLabel": "Odrzucone udziały", + "miningCurrentDiffLabel": "Aktualna trudność", + "miningMinerStartSuccess": "Miner uruchomiony", + "miningMinerStartFailure": "Nie udało się uruchomić minera", + "miningMinerStopSuccess": "Miner zatrzymany", + "miningMinerStopFailure": "Nie udało się zatrzymać minera", + "miningMinerRestartSuccess": "Miner zrestartowany", + "miningMinerRestartFailure": "Nie udało się zrestartować minera", "miningBundledTitle": "Bundlowany miner", "miningBundledEnabled": "Ten build zawiera bundlowaną binarkę XMRig.", "miningBundledDisabled": "Ten wariant releasu nie zawiera bundlowanego minera.", diff --git a/lib/services/app_paths.dart b/lib/services/app_paths.dart index 597848a..1cb2afa 100644 --- a/lib/services/app_paths.dart +++ b/lib/services/app_paths.dart @@ -55,6 +55,18 @@ class AppPaths { return File(p.join((await appSupportDir()).path, 'peyad-launcher.log')); } + static Future minerPidFile() async { + return File(p.join((await appSupportDir()).path, 'xmrig.pid')); + } + + static Future minerLogFile() async { + return File(p.join((await appSupportDir()).path, 'xmrig.log')); + } + + static Future minerLauncherLogFile() async { + return File(p.join((await appSupportDir()).path, 'xmrig-launcher.log')); + } + static Future> legacyConfigFiles() async { final targetRoot = Directory(_targetAppSupportPath()); final legacyRoot = Directory(_legacyAppSupportPath()); diff --git a/lib/services/bundled_mining_service.dart b/lib/services/bundled_mining_service.dart new file mode 100644 index 0000000..be30c08 --- /dev/null +++ b/lib/services/bundled_mining_service.dart @@ -0,0 +1,324 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:logger/logger.dart'; +import 'package:path/path.dart' as p; + +import 'app_paths.dart'; +import 'mining_service.dart'; + +class BundledMiningService implements MiningService { + BundledMiningService({required Logger logger}) : _logger = logger; + + final Logger _logger; + Future? _startFuture; + MinerLaunchConfig? _lastConfig; + + @override + Future isRunning({MinerLaunchConfig? config}) async { + final resolved = config ?? _lastConfig; + if (resolved == null) { + return false; + } + if (await _isApiAvailable(resolved)) { + return true; + } + final pid = await _readPid(); + if (pid == null) { + return false; + } + return _isProcessAlive(pid); + } + + @override + Future start({required MinerLaunchConfig config}) async { + if (_startFuture != null) { + return _startFuture!; + } + _startFuture = _startInternal(config); + try { + return await _startFuture!; + } finally { + _startFuture = null; + } + } + + @override + Future stop({MinerLaunchConfig? config}) async { + final resolved = config ?? _lastConfig; + final pidFile = await AppPaths.minerPidFile(); + final pid = await _readPid(); + if (pid == null) { + if (resolved == null) { + return true; + } + return !(await _isApiAvailable(resolved)); + } + + var stopped = Process.killPid(pid); + if (!stopped) { + _logger.w('Failed to signal xmrig process ($pid)'); + return false; + } + + if (!await _waitForExit(pid, resolved)) { + try { + Process.killPid(pid, ProcessSignal.sigkill); + } catch (_) {} + } + + final exited = await _waitForExit(pid, resolved); + if (exited && await pidFile.exists()) { + try { + await pidFile.delete(); + } catch (_) {} + } + return exited; + } + + @override + Future restart({required MinerLaunchConfig config}) async { + final stopped = await stop(config: config); + if (!stopped) { + return false; + } + return start(config: config); + } + + @override + Future fetchSummary({required MinerLaunchConfig config}) async { + HttpClient? client; + try { + client = HttpClient(); + final uri = Uri.parse('http://127.0.0.1:${config.apiPort}/1/summary'); + final request = await client.getUrl(uri); + final response = await request.close(); + if (response.statusCode != HttpStatus.ok) { + return null; + } + final body = await utf8.decoder.bind(response).join(); + final json = jsonDecode(body) as Map; + final hashrate = json['hashrate'] as Map? ?? const {}; + final total = hashrate['total'] as List? ?? const []; + final results = json['results'] as Map? ?? const {}; + final connection = + json['connection'] as Map? ?? const {}; + final sharesGood = (results['shares_good'] as num?)?.toInt() ?? 0; + final sharesTotal = (results['shares_total'] as num?)?.toInt() ?? 0; + final rejected = sharesTotal - sharesGood; + return XmrigSummary( + status: 'running', + pool: connection['pool'] as String? ?? '', + hashrate10s: _readHashrate(total, 0), + hashrate1m: _readHashrate(total, 1), + hashrate15m: _readHashrate(total, 2), + acceptedShares: sharesGood, + totalShares: sharesTotal, + rejectedShares: rejected < 0 ? 0 : rejected, + uptimeSeconds: (connection['uptime'] as num?)?.toInt() ?? 0, + currentDifficulty: (results['diff_current'] as num?)?.toInt() ?? 0, + ); + } catch (error) { + _logger.w('Failed to read xmrig summary: $error'); + return null; + } finally { + client?.close(force: true); + } + } + + Future _startInternal(MinerLaunchConfig config) async { + _lastConfig = config; + if (await _isApiAvailable(config)) { + return true; + } + final binary = await _locateBinary(); + if (binary == null) { + _logger.w('Bundled xmrig binary not found.'); + return false; + } + + final pidFile = await AppPaths.minerPidFile(); + final logFile = await AppPaths.minerLogFile(); + final launcherLogFile = await AppPaths.minerLauncherLogFile(); + await launcherLogFile.parent.create(recursive: true); + + final args = [ + '--coin', + 'PEY', + '-a', + 'rx/0', + '-u', + config.walletAddress, + '-p', + 'x', + '-t', + config.cpuThreads.clamp(1, 64).toString(), + '--http-host', + '127.0.0.1', + '--http-port', + config.apiPort.toString(), + '--log-file', + logFile.path, + '--print-time=60', + '--no-color', + ]; + + if (config.mode == MinerTargetMode.solo) { + args.addAll([ + '--daemon', + '-o', + '${config.daemonHost}:${config.daemonPort}', + '--daemon-zmq-port', + config.daemonZmqPort.toString(), + ]); + } else { + args.addAll([ + '-o', + '${config.poolHost}:${config.poolPort}', + ]); + } + + try { + final process = await Process.start( + binary, + args, + workingDirectory: p.dirname(binary), + mode: ProcessStartMode.detachedWithStdio, + ); + await pidFile.writeAsString(process.pid.toString()); + unawaited(_pipeLogs(process, launcherLogFile)); + final earlyExit = await _waitForEarlyExit(process); + if (earlyExit != null) { + _logger.w('xmrig exited immediately with code $earlyExit'); + return false; + } + return _waitForApi(config); + } catch (error) { + _logger.w('Failed to start xmrig: $error'); + return false; + } + } + + Future _pipeLogs(Process process, File logFile) async { + final sink = logFile.openWrite(mode: FileMode.writeOnlyAppend); + sink.writeln('=== ${DateTime.now().toIso8601String()} xmrig launch ==='); + unawaited( + process.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .forEach((line) => sink.writeln('[stdout] $line')), + ); + unawaited( + process.stderr + .transform(utf8.decoder) + .transform(const LineSplitter()) + .forEach((line) => sink.writeln('[stderr] $line')), + ); + unawaited( + process.exitCode.then((code) async { + sink.writeln('[exit] $code'); + await sink.flush(); + await sink.close(); + }), + ); + } + + Future _waitForEarlyExit(Process process) async { + const grace = Duration(seconds: 2); + final result = await Future.any([ + process.exitCode, + Future.delayed(grace, () => null), + ]); + return result is int ? result : null; + } + + Future _waitForApi(MinerLaunchConfig config) async { + for (var i = 0; i < 20; i++) { + if (await _isApiAvailable(config)) { + return true; + } + await Future.delayed(const Duration(seconds: 1)); + } + return false; + } + + Future _waitForExit(int pid, MinerLaunchConfig? config) async { + for (var i = 0; i < 20; i++) { + final alive = _isProcessAlive(pid); + final apiAlive = + config == null ? false : await _isApiAvailable(config); + if (!alive && !apiAlive) { + return true; + } + await Future.delayed(const Duration(milliseconds: 500)); + } + return false; + } + + Future _isApiAvailable(MinerLaunchConfig config) async { + try { + final socket = await Socket.connect( + '127.0.0.1', + config.apiPort, + timeout: const Duration(seconds: 1), + ); + socket.destroy(); + return true; + } catch (_) { + return false; + } + } + + Future _readPid() async { + final pidFile = await AppPaths.minerPidFile(); + if (!await pidFile.exists()) { + return null; + } + return int.tryParse((await pidFile.readAsString()).trim()); + } + + bool _isProcessAlive(int pid) { + if (Platform.isLinux) { + return Directory('/proc/$pid').existsSync(); + } + if (Platform.isWindows) { + try { + final result = Process.runSync( + 'tasklist', + ['/FI', 'PID eq $pid'], + runInShell: true, + ); + return result.stdout.toString().contains('$pid'); + } catch (_) { + return false; + } + } + return false; + } + + Future _locateBinary() async { + final executableDir = p.dirname(Platform.resolvedExecutable); + final cwd = Directory.current.path; + final name = Platform.isWindows ? 'xmrig.exe' : 'xmrig'; + final candidates = [ + p.join(executableDir, 'external', 'miner', name), + p.join(cwd, 'external', 'miner', name), + p.join(cwd, '..', 'external', 'miner', name), + ]; + for (final candidate in candidates) { + final file = File(candidate); + if (await file.exists()) { + return file.path; + } + } + return null; + } + + double? _readHashrate(List values, int index) { + if (index < 0 || index >= values.length) { + return null; + } + return (values[index] as num?)?.toDouble(); + } +} diff --git a/lib/services/mining_service.dart b/lib/services/mining_service.dart new file mode 100644 index 0000000..540d73f --- /dev/null +++ b/lib/services/mining_service.dart @@ -0,0 +1,59 @@ +enum MinerTargetMode { solo, pool } + +class MinerLaunchConfig { + const MinerLaunchConfig({ + required this.mode, + required this.walletAddress, + required this.cpuThreads, + required this.apiPort, + required this.poolHost, + required this.poolPort, + required this.daemonHost, + required this.daemonPort, + required this.daemonZmqPort, + }); + + final MinerTargetMode mode; + final String walletAddress; + final int cpuThreads; + final int apiPort; + final String poolHost; + final int poolPort; + final String daemonHost; + final int daemonPort; + final int daemonZmqPort; +} + +class XmrigSummary { + const XmrigSummary({ + required this.status, + required this.pool, + required this.hashrate10s, + required this.hashrate1m, + required this.hashrate15m, + required this.acceptedShares, + required this.totalShares, + required this.rejectedShares, + required this.uptimeSeconds, + required this.currentDifficulty, + }); + + final String status; + final String pool; + final double? hashrate10s; + final double? hashrate1m; + final double? hashrate15m; + final int acceptedShares; + final int totalShares; + final int rejectedShares; + final int uptimeSeconds; + final int currentDifficulty; +} + +abstract class MiningService { + Future isRunning({MinerLaunchConfig? config}); + Future start({required MinerLaunchConfig config}); + Future stop({MinerLaunchConfig? config}); + Future restart({required MinerLaunchConfig config}); + Future fetchSummary({required MinerLaunchConfig config}); +} diff --git a/lib/services/noop_mining_service.dart b/lib/services/noop_mining_service.dart new file mode 100644 index 0000000..bcffefa --- /dev/null +++ b/lib/services/noop_mining_service.dart @@ -0,0 +1,28 @@ +import 'mining_service.dart'; + +class NoopMiningService implements MiningService { + @override + Future fetchSummary({required MinerLaunchConfig config}) async { + return null; + } + + @override + Future isRunning({MinerLaunchConfig? config}) async { + return false; + } + + @override + Future restart({required MinerLaunchConfig config}) async { + return false; + } + + @override + Future start({required MinerLaunchConfig config}) async { + return false; + } + + @override + Future stop({MinerLaunchConfig? config}) async { + return false; + } +} diff --git a/lib/state/mining_controller.dart b/lib/state/mining_controller.dart new file mode 100644 index 0000000..d92314d --- /dev/null +++ b/lib/state/mining_controller.dart @@ -0,0 +1,216 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logger/logger.dart'; + +import '../domain/models.dart'; +import '../services/mining_service.dart'; +import 'wallet_controller.dart'; + +class MiningState { + const MiningState({ + required this.running, + required this.busy, + required this.summary, + required this.error, + }); + + final bool running; + final bool busy; + final XmrigSummary? summary; + final String? error; + + MiningState copyWith({ + bool? running, + bool? busy, + XmrigSummary? summary, + String? error, + }) { + return MiningState( + running: running ?? this.running, + busy: busy ?? this.busy, + summary: summary ?? this.summary, + error: error, + ); + } + + factory MiningState.initial() { + return const MiningState( + running: false, + busy: false, + summary: null, + error: null, + ); + } +} + +class MiningController extends StateNotifier { + MiningController({ + required MiningService service, + required Logger logger, + required AppConfig Function() readConfig, + required WalletState Function() readWalletState, + }) : _service = service, + _logger = logger, + _readConfig = readConfig, + _readWalletState = readWalletState, + super(MiningState.initial()) { + _timer = Timer.periodic(const Duration(seconds: 2), (_) => refresh()); + unawaited(refresh()); + } + + final MiningService _service; + final Logger _logger; + final AppConfig Function() _readConfig; + final WalletState Function() _readWalletState; + Timer? _timer; + + Future start() async { + return _runAction(() async { + final config = _buildLaunchConfig(); + final started = await _service.start(config: config); + if (!started) { + throw StateError('Failed to start miner'); + } + await refresh(); + return true; + }); + } + + Future stop() async { + return _runAction(() async { + final config = _tryBuildLaunchConfig(); + final stopped = await _service.stop(config: config); + if (!stopped) { + throw StateError('Failed to stop miner'); + } + await refresh(); + return true; + }); + } + + Future restart() async { + return _runAction(() async { + final config = _buildLaunchConfig(); + final restarted = await _service.restart(config: config); + if (!restarted) { + throw StateError('Failed to restart miner'); + } + await refresh(); + return true; + }); + } + + Future refresh() async { + final config = _tryBuildLaunchConfig(); + if (config == null) { + state = state.copyWith( + running: false, + summary: null, + error: state.busy ? state.error : null, + ); + return; + } + try { + final running = await _service.isRunning(config: config); + final summary = running ? await _service.fetchSummary(config: config) : null; + state = state.copyWith( + running: running, + summary: summary, + error: null, + ); + } catch (error, stack) { + _logger.w('Failed to refresh miner state', error: error, stackTrace: stack); + state = state.copyWith(error: error.toString()); + } + } + + Future _runAction(Future Function() action) async { + if (state.busy) { + return false; + } + state = state.copyWith(busy: true, error: null); + try { + return await action(); + } catch (error, stack) { + _logger.w('Mining action failed', error: error, stackTrace: stack); + state = state.copyWith(error: error.toString()); + return false; + } finally { + state = state.copyWith(busy: false); + } + } + + MinerLaunchConfig? _tryBuildLaunchConfig() { + try { + return _buildLaunchConfig(); + } catch (_) { + return null; + } + } + + MinerLaunchConfig _buildLaunchConfig() { + final config = _readConfig(); + final wallet = _readWalletState().walletInfo; + final address = wallet?.address.trim() ?? ''; + if (address.isEmpty) { + throw StateError('Open a wallet before starting the miner'); + } + + final mining = config.miningConfig; + final localNode = _resolveLocalNodePorts(config.localNodeArgs); + final mode = + mining.mode == MiningMode.solo ? MinerTargetMode.solo : MinerTargetMode.pool; + + if (mode == MinerTargetMode.solo && config.nodeConfig.mode != NodeMode.local) { + throw StateError('Solo mining requires the wallet to use the local node'); + } + + return MinerLaunchConfig( + mode: mode, + walletAddress: address, + cpuThreads: mining.cpuThreads.clamp(1, 64), + apiPort: mining.apiPort, + poolHost: mining.poolHost, + poolPort: mining.poolPort, + daemonHost: localNode.host, + daemonPort: localNode.rpcPort, + daemonZmqPort: localNode.zmqPort, + ); + } + + ({String host, int rpcPort, int zmqPort}) _resolveLocalNodePorts( + List args, + ) { + var host = '127.0.0.1'; + var rpcPort = 17750; + var zmqPort = 17751; + for (var i = 0; i < args.length; i++) { + final arg = args[i]; + if (arg.startsWith('--rpc-bind-ip=')) { + host = arg.split('=').last.trim(); + } else if (arg == '--rpc-bind-ip' && i + 1 < args.length) { + host = args[i + 1].trim(); + } else if (arg.startsWith('--rpc-bind-port=')) { + rpcPort = int.tryParse(arg.split('=').last.trim()) ?? rpcPort; + } else if (arg == '--rpc-bind-port' && i + 1 < args.length) { + rpcPort = int.tryParse(args[i + 1].trim()) ?? rpcPort; + } else if (arg.startsWith('--zmq-rpc-bind-port=')) { + zmqPort = int.tryParse(arg.split('=').last.trim()) ?? zmqPort; + } else if (arg == '--zmq-rpc-bind-port' && i + 1 < args.length) { + zmqPort = int.tryParse(args[i + 1].trim()) ?? zmqPort; + } + } + + if (host == '0.0.0.0' || host == '::' || host == '[::]') { + host = '127.0.0.1'; + } + return (host: host, rpcPort: rpcPort, zmqPort: zmqPort); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } +} diff --git a/lib/state/providers.dart b/lib/state/providers.dart index 946bfae..8a19301 100644 --- a/lib/state/providers.dart +++ b/lib/state/providers.dart @@ -9,14 +9,18 @@ import '../domain/models.dart'; import '../native/wallet_backend.dart'; import '../native/wallet_backend_factory.dart'; import '../services/linux_local_node_service.dart'; +import '../services/bundled_mining_service.dart'; import '../services/local_node_service.dart'; +import '../services/mining_service.dart'; import '../services/noop_local_node_service.dart'; +import '../services/noop_mining_service.dart'; import '../services/noop_tray_service.dart'; import '../services/sync_scheduler.dart'; import '../services/tray_service.dart'; import '../services/windows_local_node_service.dart'; import '../services/window_lifecycle_service.dart'; import 'app_config_controller.dart'; +import 'mining_controller.dart'; import 'wallet_controller.dart'; final loggerProvider = Provider((ref) => Logger()); @@ -75,6 +79,24 @@ final localNodeServiceProvider = Provider((ref) { return NoopLocalNodeService(); }); +final miningServiceProvider = Provider((ref) { + final logger = ref.watch(loggerProvider); + if (Platform.isLinux || Platform.isWindows) { + return BundledMiningService(logger: logger); + } + return NoopMiningService(); +}); + +final miningControllerProvider = + StateNotifierProvider((ref) { + return MiningController( + service: ref.watch(miningServiceProvider), + logger: ref.watch(loggerProvider), + readConfig: () => ref.read(appConfigControllerProvider), + readWalletState: () => ref.read(walletControllerProvider), + ); +}); + final windowLifecycleServiceProvider = Provider((ref) { final tray = ref.read(trayServiceProvider); return WindowLifecycleService( diff --git a/lib/ui/screens/mining_screen.dart b/lib/ui/screens/mining_screen.dart index 32421b1..3a50d04 100644 --- a/lib/ui/screens/mining_screen.dart +++ b/lib/ui/screens/mining_screen.dart @@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart'; import '../../domain/models.dart'; +import '../../state/mining_controller.dart'; import '../../state/providers.dart'; import '../l10n/app_localizations_ext.dart'; @@ -43,6 +44,7 @@ class _MiningScreenState extends ConsumerState { final theme = Theme.of(context); final config = ref.watch(appConfigControllerProvider); final walletState = ref.watch(walletControllerProvider); + final miningState = ref.watch(miningControllerProvider); final maxThreads = math.max(1, math.min(Platform.numberOfProcessors, 64)); return SingleChildScrollView( @@ -67,138 +69,159 @@ class _MiningScreenState extends ConsumerState { ), ), const SizedBox(height: 24), - Column( - children: [ - _SectionCard( - title: l10n.miningControlsTitle, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - spacing: 12, - runSpacing: 12, - children: [ - FilledButton.icon( - onPressed: null, - icon: const Icon(Symbols.play_arrow), - label: Text(l10n.miningStartAction), - ), - OutlinedButton.icon( - onPressed: null, - icon: const Icon(Symbols.stop), - label: Text(l10n.miningStopAction), - ), - OutlinedButton.icon( - onPressed: null, - icon: const Icon(Symbols.refresh), - label: Text(l10n.localNodeRestartAction), - ), - OutlinedButton.icon( - onPressed: () => _showSettingsDialog(maxThreads), - icon: const Icon(Symbols.settings), - label: Text(l10n.miningSettingsAction), - ), - ], - ), - const SizedBox(height: 12), - Text( - l10n.miningBackendPending, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - const SizedBox(height: 16), - _SectionCard( - title: l10n.miningCurrentConfigTitle, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _InfoTile( - label: l10n.miningWalletAddressLabel, - value: walletState.walletInfo?.address.isNotEmpty == true - ? walletState.walletInfo!.address - : l10n.miningNoWalletAddress, - icon: Symbols.account_balance_wallet, - ), - const SizedBox(height: 12), - _InfoTile( - label: l10n.miningModeLabel, - value: config.miningConfig.mode == MiningMode.solo - ? l10n.miningModeSolo - : '${config.miningConfig.poolHost}:${config.miningConfig.poolPort}', - icon: config.miningConfig.mode == MiningMode.solo - ? Symbols.lan - : Symbols.hub, - ), - const SizedBox(height: 12), - _InfoTile( - label: l10n.miningTargetLabel, - value: config.miningConfig.mode == MiningMode.solo - ? l10n.miningSoloTargetValue - : '${config.miningConfig.poolHost}:${config.miningConfig.poolPort}', - icon: Symbols.route, - ), - const SizedBox(height: 12), - _InfoTile( - label: l10n.miningThreadsLabel, - value: config.miningConfig.cpuThreads.toString(), - icon: Symbols.memory, - ), - const SizedBox(height: 12), - _InfoTile( - label: l10n.miningApiPortLabel, - value: config.miningConfig.apiPort.toString(), - icon: Symbols.settings_ethernet, - ), - ], - ), - ), - const SizedBox(height: 16), - _SectionCard( - title: l10n.miningStatsTitle, - child: Wrap( + _SectionCard( + title: l10n.miningControlsTitle, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( spacing: 12, runSpacing: 12, children: [ - _StatTile( - label: l10n.transactionStatusLabel, - value: l10n.comingSoon, + FilledButton.icon( + onPressed: miningState.busy || miningState.running + ? null + : _startMining, + icon: const Icon(Symbols.play_arrow), + label: Text(l10n.miningStartAction), ), - _StatTile( - label: l10n.miningHashrate10sLabel, - value: l10n.miningDataUnknownValue, + OutlinedButton.icon( + onPressed: miningState.busy || !miningState.running + ? null + : _stopMining, + icon: const Icon(Symbols.stop), + label: Text(l10n.miningStopAction), ), - _StatTile( - label: l10n.miningHashrate1mLabel, - value: l10n.miningDataUnknownValue, + OutlinedButton.icon( + onPressed: miningState.busy || !miningState.running + ? null + : _restartMining, + icon: const Icon(Symbols.refresh), + label: Text(l10n.localNodeRestartAction), ), - _StatTile( - label: l10n.miningLabelHashrate15m, - value: l10n.miningDataUnknownValue, - ), - _StatTile( - label: l10n.miningJobsLabel, - value: l10n.miningDataUnknownValue, - ), - _StatTile( - label: l10n.miningAcceptedSharesLabel, - value: l10n.miningDataUnknownValue, - ), - _StatTile( - label: l10n.miningRejectedSharesLabel, - value: l10n.miningDataUnknownValue, - ), - _StatTile( - label: l10n.miningLabelUptime, - value: l10n.miningDataUnknownValue, + OutlinedButton.icon( + onPressed: () => _showSettingsDialog(maxThreads), + icon: const Icon(Symbols.settings), + label: Text(l10n.miningSettingsAction), ), ], ), - ), - ], + const SizedBox(height: 12), + Text( + miningState.error ?? + (miningState.running + ? l10n.miningStatusRunning + : l10n.miningStatusStopped), + style: theme.textTheme.bodyMedium?.copyWith( + color: miningState.error == null + ? theme.colorScheme.onSurfaceVariant + : theme.colorScheme.error, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + _SectionCard( + title: l10n.miningCurrentConfigTitle, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _InfoTile( + label: l10n.miningWalletAddressLabel, + value: walletState.walletInfo?.address.isNotEmpty == true + ? walletState.walletInfo!.address + : l10n.miningNoWalletAddress, + icon: Symbols.account_balance_wallet, + ), + const SizedBox(height: 12), + _InfoTile( + label: l10n.miningModeLabel, + value: config.miningConfig.mode == MiningMode.solo + ? l10n.miningModeSolo + : l10n.miningModePool, + icon: config.miningConfig.mode == MiningMode.solo + ? Symbols.lan + : Symbols.hub, + ), + const SizedBox(height: 12), + _InfoTile( + label: l10n.miningTargetLabel, + value: config.miningConfig.mode == MiningMode.solo + ? l10n.miningSoloTargetValue + : '${config.miningConfig.poolHost}:${config.miningConfig.poolPort}', + icon: Symbols.route, + ), + const SizedBox(height: 12), + _InfoTile( + label: l10n.miningThreadsLabel, + value: config.miningConfig.cpuThreads.toString(), + icon: Symbols.memory, + ), + const SizedBox(height: 12), + _InfoTile( + label: l10n.miningApiPortLabel, + value: config.miningConfig.apiPort.toString(), + icon: Symbols.settings_ethernet, + ), + const SizedBox(height: 12), + _InfoTile( + label: l10n.miningCurrentDiffLabel, + value: miningState.summary?.currentDifficulty.toString() ?? + l10n.miningDataUnknownValue, + icon: Symbols.speed, + ), + ], + ), + ), + const SizedBox(height: 16), + _SectionCard( + title: l10n.miningStatsTitle, + child: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _StatTile( + label: l10n.transactionStatusLabel, + value: miningState.running + ? l10n.miningStatusRunning + : l10n.miningStatusStopped, + ), + _StatTile( + label: l10n.miningHashrate10sLabel, + value: _formatHashrate(miningState.summary?.hashrate10s, l10n), + ), + _StatTile( + label: l10n.miningHashrate1mLabel, + value: _formatHashrate(miningState.summary?.hashrate1m, l10n), + ), + _StatTile( + label: l10n.miningLabelHashrate15m, + value: _formatHashrate(miningState.summary?.hashrate15m, l10n), + ), + _StatTile( + label: l10n.miningJobsLabel, + value: l10n.miningDataUnknownValue, + ), + _StatTile( + label: l10n.miningAcceptedSharesLabel, + value: miningState.summary?.acceptedShares.toString() ?? + l10n.miningDataUnknownValue, + ), + _StatTile( + label: l10n.miningRejectedSharesLabel, + value: miningState.summary?.rejectedShares.toString() ?? + l10n.miningDataUnknownValue, + ), + _StatTile( + label: l10n.miningLabelUptime, + value: _formatUptime( + miningState.summary?.uptimeSeconds, + l10n, + ), + ), + ], + ), ), ], ), @@ -242,6 +265,7 @@ class _MiningScreenState extends ConsumerState { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l10n.miningSettingsSaved)), ); + await ref.read(miningControllerProvider.notifier).refresh(); } Future _showSettingsDialog(int maxThreads) async { @@ -257,6 +281,7 @@ class _MiningScreenState extends ConsumerState { width: 520, child: StatefulBuilder( builder: (context, setDialogState) { + final nodeMode = ref.read(appConfigControllerProvider).nodeConfig.mode; return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -297,13 +322,11 @@ class _MiningScreenState extends ConsumerState { ), const SizedBox(height: 12), Text( - ref.read(appConfigControllerProvider).nodeConfig.mode == - NodeMode.local + nodeMode == NodeMode.local ? l10n.miningSoloHint : l10n.miningSoloNeedsLocalNode, style: theme.textTheme.bodyMedium?.copyWith( - color: ref.read(appConfigControllerProvider).nodeConfig.mode == - NodeMode.local + color: nodeMode == NodeMode.local ? theme.colorScheme.onSurfaceVariant : theme.colorScheme.error, ), @@ -392,6 +415,76 @@ class _MiningScreenState extends ConsumerState { }, ); } + + Future _startMining() async { + final l10n = context.l10n; + final started = await ref.read(miningControllerProvider.notifier).start(); + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + started ? l10n.miningMinerStartSuccess : l10n.miningMinerStartFailure, + ), + ), + ); + } + + Future _stopMining() async { + final l10n = context.l10n; + final stopped = await ref.read(miningControllerProvider.notifier).stop(); + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + stopped ? l10n.miningMinerStopSuccess : l10n.miningMinerStopFailure, + ), + ), + ); + } + + Future _restartMining() async { + final l10n = context.l10n; + final restarted = await ref.read(miningControllerProvider.notifier).restart(); + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + restarted + ? l10n.miningMinerRestartSuccess + : l10n.miningMinerRestartFailure, + ), + ), + ); + } + + String _formatHashrate(double? value, dynamic l10n) { + if (value == null) { + return l10n.miningDataUnknownValue; + } + return '${value.toStringAsFixed(1)} H/s'; + } + + String _formatUptime(int? seconds, dynamic l10n) { + if (seconds == null) { + return l10n.miningDataUnknownValue; + } + final hours = seconds ~/ 3600; + final minutes = (seconds % 3600) ~/ 60; + final secs = seconds % 60; + if (hours > 0) { + return '${hours}h ${minutes}m'; + } + if (minutes > 0) { + return '${minutes}m ${secs}s'; + } + return '${secs}s'; + } } class _SectionCard extends StatelessWidget { @@ -403,21 +496,24 @@ class _SectionCard extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - return Card( - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, + return SizedBox( + width: double.infinity, + child: Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), ), - ), - const SizedBox(height: 16), - child, - ], + const SizedBox(height: 16), + child, + ], + ), ), ), );