Add xmrig service layer to peyawallet
build / Build Linux (lite) (push) Failing after 13s
build / Build Linux (mining) (push) Successful in 3m39s
build / Build Windows (mining) (push) Has been cancelled
build / Build Windows (lite) (push) Has been cancelled

This commit is contained in:
Codex Bot
2026-04-20 23:31:05 +02:00
parent b07b84c3cb
commit 067f1bc2f8
12 changed files with 1024 additions and 141 deletions
+9
View File
@@ -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.",
+54
View File
@@ -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:
+27
View File
@@ -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';
+27
View File
@@ -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';
+9
View File
@@ -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.",
+12
View File
@@ -55,6 +55,18 @@ class AppPaths {
return File(p.join((await appSupportDir()).path, 'peyad-launcher.log'));
}
static Future<File> minerPidFile() async {
return File(p.join((await appSupportDir()).path, 'xmrig.pid'));
}
static Future<File> minerLogFile() async {
return File(p.join((await appSupportDir()).path, 'xmrig.log'));
}
static Future<File> minerLauncherLogFile() async {
return File(p.join((await appSupportDir()).path, 'xmrig-launcher.log'));
}
static Future<List<File>> legacyConfigFiles() async {
final targetRoot = Directory(_targetAppSupportPath());
final legacyRoot = Directory(_legacyAppSupportPath());
+324
View File
@@ -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<bool>? _startFuture;
MinerLaunchConfig? _lastConfig;
@override
Future<bool> 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<bool> start({required MinerLaunchConfig config}) async {
if (_startFuture != null) {
return _startFuture!;
}
_startFuture = _startInternal(config);
try {
return await _startFuture!;
} finally {
_startFuture = null;
}
}
@override
Future<bool> 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<bool> restart({required MinerLaunchConfig config}) async {
final stopped = await stop(config: config);
if (!stopped) {
return false;
}
return start(config: config);
}
@override
Future<XmrigSummary?> 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<String, dynamic>;
final hashrate = json['hashrate'] as Map<String, dynamic>? ?? const {};
final total = hashrate['total'] as List<dynamic>? ?? const [];
final results = json['results'] as Map<String, dynamic>? ?? const {};
final connection =
json['connection'] as Map<String, dynamic>? ?? 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<bool> _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 = <String>[
'--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<void> _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<int?> _waitForEarlyExit(Process process) async {
const grace = Duration(seconds: 2);
final result = await Future.any<Object?>([
process.exitCode,
Future<Object?>.delayed(grace, () => null),
]);
return result is int ? result : null;
}
Future<bool> _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<bool> _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<bool> _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<int?> _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<String?> _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<dynamic> values, int index) {
if (index < 0 || index >= values.length) {
return null;
}
return (values[index] as num?)?.toDouble();
}
}
+59
View File
@@ -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<bool> isRunning({MinerLaunchConfig? config});
Future<bool> start({required MinerLaunchConfig config});
Future<bool> stop({MinerLaunchConfig? config});
Future<bool> restart({required MinerLaunchConfig config});
Future<XmrigSummary?> fetchSummary({required MinerLaunchConfig config});
}
+28
View File
@@ -0,0 +1,28 @@
import 'mining_service.dart';
class NoopMiningService implements MiningService {
@override
Future<XmrigSummary?> fetchSummary({required MinerLaunchConfig config}) async {
return null;
}
@override
Future<bool> isRunning({MinerLaunchConfig? config}) async {
return false;
}
@override
Future<bool> restart({required MinerLaunchConfig config}) async {
return false;
}
@override
Future<bool> start({required MinerLaunchConfig config}) async {
return false;
}
@override
Future<bool> stop({MinerLaunchConfig? config}) async {
return false;
}
}
+216
View File
@@ -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<MiningState> {
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<bool> 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<bool> 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<bool> 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<void> 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<bool> _runAction(Future<bool> 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<String> 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();
}
}
+22
View File
@@ -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<Logger>((ref) => Logger());
@@ -75,6 +79,24 @@ final localNodeServiceProvider = Provider<LocalNodeService>((ref) {
return NoopLocalNodeService();
});
final miningServiceProvider = Provider<MiningService>((ref) {
final logger = ref.watch(loggerProvider);
if (Platform.isLinux || Platform.isWindows) {
return BundledMiningService(logger: logger);
}
return NoopMiningService();
});
final miningControllerProvider =
StateNotifierProvider<MiningController, MiningState>((ref) {
return MiningController(
service: ref.watch(miningServiceProvider),
logger: ref.watch(loggerProvider),
readConfig: () => ref.read(appConfigControllerProvider),
readWalletState: () => ref.read(walletControllerProvider),
);
});
final windowLifecycleServiceProvider = Provider<WindowLifecycleService>((ref) {
final tray = ref.read(trayServiceProvider);
return WindowLifecycleService(
+237 -141
View File
@@ -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<MiningScreen> {
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<MiningScreen> {
),
),
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<MiningScreen> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.miningSettingsSaved)),
);
await ref.read(miningControllerProvider.notifier).refresh();
}
Future<void> _showSettingsDialog(int maxThreads) async {
@@ -257,6 +281,7 @@ class _MiningScreenState extends ConsumerState<MiningScreen> {
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<MiningScreen> {
),
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<MiningScreen> {
},
);
}
Future<void> _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<void> _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<void> _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,
],
),
),
),
);