云算力平台windows桌面应用

This commit is contained in:
lzx
2026-01-22 15:14:27 +08:00
commit 1fe0e54138
52 changed files with 5447 additions and 0 deletions

349
lib/core/client_core.dart Normal file
View File

@@ -0,0 +1,349 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:logging/logging.dart';
import 'mining_task_info.dart';
import '../models/client_status.dart' as ui;
/// 客户端核心 - 实现与服务器通信、心跳、挖矿管理等核心功能
class ClientCore {
static final ClientCore _instance = ClientCore._internal();
factory ClientCore() => _instance;
ClientCore._internal();
final Logger _logger = Logger('ClientCore');
Socket? _socket;
String? _serverUrl;
String? _auth;
String? _machineCode;
bool _isConnected = false;
DateTime? _lastPingTime;
Timer? _heartbeatTimer;
StreamController<String>? _logController;
// 需要GPU和挖矿软件信息用于认证
Map<String, dynamic>? _gpusInfo;
List<String>? _miningSofts;
// 状态回调
Function(ui.ClientStatus)? onStatusChanged;
Function(MiningTaskInfo?)? onMiningTaskChanged;
Stream<String> get logStream => _logController?.stream ?? const Stream.empty();
bool get isConnected => _isConnected;
/// 更新系统信息(用于后台异步加载后更新)
void setSystemInfo(Map<String, dynamic> gpusInfo, List<String> miningSofts) {
_gpusInfo = gpusInfo;
_miningSofts = miningSofts;
// 如果已连接,重新发送认证消息更新服务器
if (_isConnected) {
_sendMachineCode();
}
}
/// 初始化客户端
Future<bool> initialize({
required String serverUrl,
required String auth,
required String machineCode,
Map<String, dynamic>? gpusInfo,
List<String>? miningSofts,
}) async {
_serverUrl = serverUrl;
_auth = auth;
_machineCode = machineCode;
_gpusInfo = gpusInfo;
_miningSofts = miningSofts;
_logController = StreamController<String>.broadcast();
try {
await _connect();
// 注意:不在这里发送身份认证,等待机器码获取完成后再发送
_startHeartbeat();
return true;
} catch (e) {
_logger.severe('初始化失败: $e');
return false;
}
}
/// 连接到服务器
Future<void> _connect() async {
if (_serverUrl == null) {
throw Exception('服务器地址未设置');
}
try {
final parts = _serverUrl!.split(':');
if (parts.length != 2) {
throw Exception('服务器地址格式错误');
}
final host = parts[0];
final port = int.parse(parts[1]);
_socket = await Socket.connect(host, port, timeout: const Duration(seconds: 10));
_isConnected = true;
_log('连接到服务器成功: $_serverUrl');
// 注意:不在这里发送身份认证,等待机器码获取完成后再发送
// 身份认证消息将在机器码获取完成后通过 sendMachineCode() 发送
// 开始接收消息
_socket!.listen(
_onDataReceived,
onError: _onError,
onDone: _onDone,
cancelOnError: false,
);
onStatusChanged?.call(ui.ClientStatus.online);
} catch (e) {
_isConnected = false;
_log('连接失败: $e');
onStatusChanged?.call(ui.ClientStatus.offline);
rethrow;
}
}
/// 更新机器码并发送身份认证(等待硬盘身份码获取完成后调用)
void updateMachineCode(String machineCode, Map<String, dynamic> gpusInfo, List<String> miningSofts) {
_machineCode = machineCode;
_gpusInfo = gpusInfo;
_miningSofts = miningSofts;
}
/// 发送身份认证消息(公开方法,供外部调用)
void sendMachineCode() {
_sendMachineCode();
}
/// 发送机器码认证消息(内部方法)
void _sendMachineCode() {
if (_auth == null || _machineCode == null) {
_log('身份信息未设置');
return;
}
// 使用 身份信息::硬盘身份码 格式
final msg = {
'id': '$_auth::$_machineCode',
'method': 'auth.machineCode',
'params': {
'gpus': _gpusInfo ?? {},
'miningsofts': _miningSofts ?? [],
},
};
_sendMessage(msg);
_log('发送身份认证消息: $_auth::$_machineCode');
}
/// 发送消息到服务器
void _sendMessage(Map<String, dynamic> message) {
if (!_isConnected || _socket == null) {
_log('连接未建立,无法发送消息');
return;
}
try {
final jsonStr = jsonEncode(message);
_socket!.add(utf8.encode('$jsonStr\n'));
} catch (e) {
_log('发送消息失败: $e');
}
}
/// 接收数据
void _onDataReceived(List<int> data) {
try {
final message = utf8.decode(data);
final lines = message.split('\n').where((line) => line.trim().isNotEmpty);
for (final line in lines) {
_handleMessage(line);
}
} catch (e) {
_log('处理接收数据失败: $e');
}
}
/// 处理接收到的消息
void _handleMessage(String messageJson) {
try {
final msg = jsonDecode(messageJson) as Map<String, dynamic>;
final method = msg['method'] as String?;
_log('收到消息: $method');
if (method == 'ping') {
_handlePing(msg);
} else if (method == 'mining.req') {
_handleMiningRequest(msg);
} else if (method == 'mining.end') {
_handleMiningEnd(msg);
}
} catch (e) {
_log('处理消息失败: $e, 原始数据: $messageJson');
}
}
/// 处理 ping 消息
void _handlePing(Map<String, dynamic> msg) {
_lastPingTime = DateTime.now();
// 回复 pong
final pongMsg = {
'id': msg['id'],
'method': 'pong',
'params': null,
};
_sendMessage(pongMsg);
}
/// 处理挖矿请求
void _handleMiningRequest(Map<String, dynamic> msg) {
try {
final params = msg['params'] as Map<String, dynamic>?;
if (params == null) {
_sendMiningResponse(msg['id'] as String, false, '参数为空');
return;
}
// 注意miner 需要从配置中获取,这里先使用默认值
params['miner'] = params['miner'] ?? 'lolminer';
final task = MiningTaskInfo.fromJson(params);
onMiningTaskChanged?.call(task);
// 启动挖矿软件由 ClientProvider 处理
// 这里只负责响应成功
final respData = {
'coin': task.coin,
'algo': task.algo,
'pool': task.pool,
'pool_url': task.poolUrl,
'worker_id': task.workerId,
'wallet_address': task.walletAddress,
'watch_url': '',
};
_sendMiningResponse(msg['id'] as String, true, respData);
} catch (e) {
_log('处理挖矿请求失败: $e');
_sendMiningResponse(msg['id'] as String, false, e.toString());
}
}
/// 发送挖矿响应
void _sendMiningResponse(String id, bool success, dynamic data) {
final resp = {
'id': id,
'method': 'mining.resp',
'params': success ? data : data.toString(),
};
_sendMessage(resp);
}
/// 处理挖矿结束消息
void _handleMiningEnd(Map<String, dynamic> msg) {
_log('收到挖矿结束消息');
// 通知 ClientProvider 停止挖矿(通过回调实现)
onMiningTaskChanged?.call(null);
}
/// 错误处理
void _onError(dynamic error) {
// 检查是否正在停止,避免在停止过程中执行重连
if (_socket == null) {
return; // 已经停止,不执行后续操作
}
_log('连接错误: $error');
_isConnected = false;
onStatusChanged?.call(ui.ClientStatus.offline);
_reconnect();
}
/// 连接关闭
void _onDone() {
// 检查是否正在停止,避免在停止过程中执行重连
if (_socket == null) {
return; // 已经停止,不执行后续操作
}
_log('连接已关闭');
_isConnected = false;
onStatusChanged?.call(ui.ClientStatus.offline);
_reconnect();
}
/// 重连
void _reconnect() {
Future.delayed(const Duration(seconds: 5), () {
// 检查是否正在停止或已停止
if (_socket == null || _logController == null) {
return; // 已经停止,不执行重连
}
if (!_isConnected) {
_log('尝试重新连接...');
_connect().catchError((e) {
_log('重连失败: $e');
});
}
});
}
/// 启动心跳检查
void _startHeartbeat() {
_heartbeatTimer?.cancel();
_heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (timer) {
if (_lastPingTime != null) {
final duration = DateTime.now().difference(_lastPingTime!);
if (duration.inMinutes > 60) {
_log('超过60分钟未收到心跳连接可能已断开');
_isConnected = false;
onStatusChanged?.call(ui.ClientStatus.offline);
_reconnect();
}
}
});
}
void _log(String message) {
final logMsg = '[${DateTime.now().toString()}] $message';
_logger.info(logMsg);
// 检查 controller 是否已关闭,避免向已关闭的 controller 添加事件
try {
if (_logController != null && !_logController!.isClosed) {
_logController!.add(logMsg);
}
} catch (e) {
// 忽略已关闭的 controller 错误
}
}
/// 停止客户端
void stop() {
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
// 先取消 socket 监听,避免 onDone 回调在关闭 controller 后执行
_socket?.destroy();
_socket = null;
// 延迟关闭 logController确保所有回调都已完成
Future.microtask(() {
if (_logController != null && !_logController!.isClosed) {
_logController!.close();
}
_logController = null;
});
_isConnected = false;
onStatusChanged?.call(ui.ClientStatus.offline);
}
}

185
lib/core/database.dart Normal file
View File

@@ -0,0 +1,185 @@
import 'dart:async';
// import 'dart:io';
import 'package:logging/logging.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'mining_task_info.dart';
import '../utils/path_utils.dart';
/// 数据库管理服务
class DatabaseService {
static final DatabaseService _instance = DatabaseService._internal();
factory DatabaseService() => _instance;
DatabaseService._internal();
final Logger _logger = Logger('DatabaseService');
Database? _database;
/// 初始化数据库
Future<void> initialize() async {
try {
// Windows/Desktop: 使用 sqflite_common_ffi
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
// 与 Go 版一致,尽量落到 ./bin/mining_task.db
final dbPath = PathUtils.binFile('mining_task.db');
_database = await databaseFactory.openDatabase(
dbPath,
options: OpenDatabaseOptions(
version: 1,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE mining_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
coin TEXT NOT NULL,
algo TEXT NOT NULL,
pool TEXT NOT NULL,
pool_url TEXT NOT NULL,
wallet_address TEXT NOT NULL,
worker_id TEXT NOT NULL,
pool_user TEXT,
wallet_mining INTEGER NOT NULL,
end_timestamp INTEGER NOT NULL,
miner TEXT NOT NULL,
status TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
''');
},
),
);
_logger.info('数据库初始化成功');
} catch (e) {
_logger.severe('数据库初始化失败: $e');
rethrow;
}
}
/// 插入挖矿任务
Future<int> insertMiningTask(MiningTaskInfo task) async {
if (_database == null) {
await initialize();
}
try {
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
return await _database!.insert(
'mining_tasks',
{
'coin': task.coin,
'algo': task.algo,
'pool': task.pool,
'pool_url': task.poolUrl,
'wallet_address': task.walletAddress,
'worker_id': task.workerId,
'pool_user': task.poolUser,
'wallet_mining': task.walletMining ? 1 : 0,
'end_timestamp': task.endTimestamp,
'miner': task.miner,
'status': 'running',
'created_at': now,
'updated_at': now,
},
);
} catch (e) {
_logger.severe('插入挖矿任务失败: $e');
rethrow;
}
}
/// 完成挖矿任务
Future<void> finishMiningTask(int taskId) async {
if (_database == null) {
await initialize();
}
try {
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
await _database!.update(
'mining_tasks',
{
'status': 'finished',
'updated_at': now,
},
where: 'id = ?',
whereArgs: [taskId],
);
} catch (e) {
_logger.severe('完成挖矿任务失败: $e');
}
}
/// 加载未完成的挖矿任务
Future<MiningTaskInfo?> loadUnfinishedTask() async {
if (_database == null) {
await initialize();
}
try {
final results = await _database!.query(
'mining_tasks',
where: 'status = ?',
whereArgs: ['running'],
orderBy: 'created_at DESC',
limit: 1,
);
if (results.isEmpty) {
return null;
}
final row = results.first;
final currentTime = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final endTimestamp = row['end_timestamp'] as int;
if (currentTime >= endTimestamp) {
// 任务已过期,标记为完成
await finishMiningTask(row['id'] as int);
return null;
}
return MiningTaskInfo(
coin: row['coin'] as String,
algo: row['algo'] as String,
pool: row['pool'] as String,
poolUrl: row['pool_url'] as String,
walletAddress: row['wallet_address'] as String,
workerId: row['worker_id'] as String,
poolUser: row['pool_user'] as String?,
walletMining: (row['wallet_mining'] as int) == 1,
endTimestamp: endTimestamp,
miner: row['miner'] as String,
);
} catch (e) {
_logger.severe('加载挖矿任务失败: $e');
return null;
}
}
/// 获取任务历史
Future<List<Map<String, dynamic>>> getTaskHistory({int limit = 100}) async {
if (_database == null) {
await initialize();
}
try {
return await _database!.query(
'mining_tasks',
orderBy: 'created_at DESC',
limit: limit,
);
} catch (e) {
_logger.severe('获取任务历史失败: $e');
return [];
}
}
/// 关闭数据库
Future<void> close() async {
await _database?.close();
_database = null;
}
}

View File

@@ -0,0 +1,214 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:logging/logging.dart';
import 'mining_task_info.dart';
/// 挖矿管理器 - 管理挖矿软件的启动、停止
class MiningManager {
static final MiningManager _instance = MiningManager._internal();
factory MiningManager() => _instance;
MiningManager._internal();
final Logger _logger = Logger('MiningManager');
Process? _currentProcess;
MiningTaskInfo? _currentTask;
Timer? _taskMonitor;
final StreamController<String> _minerLogController = StreamController<String>.broadcast();
/// 启动挖矿
Future<bool> startMining(MiningTaskInfo task, MiningConfig config) async {
if (_currentProcess != null) {
_logger.warning('已有挖矿任务在运行');
return false;
}
try {
final minerPath = _getMinerPath(task.miner, config);
if (minerPath == null) {
_logger.severe('挖矿软件路径未配置: ${task.miner}');
return false;
}
final args = _buildMinerArgs(task, config);
String executable;
switch (task.miner.toLowerCase()) {
case 'lolminer':
executable = Platform.isWindows
? '${minerPath}\\lolMiner.exe'
: '$minerPath/lolMiner';
break;
case 'rigel':
executable = Platform.isWindows
? '${minerPath}\\rigel.exe'
: '$minerPath/rigel';
break;
case 'bzminer':
executable = Platform.isWindows
? '${minerPath}\\bzminer.exe'
: '$minerPath/bzminer';
break;
default:
throw Exception('不支持的挖矿软件: ${task.miner}');
}
// 确保可执行文件存在
final executableFile = File(executable);
if (!await executableFile.exists()) {
throw Exception('挖矿软件不存在: $executable');
}
_logger.info('启动挖矿: $executable ${args.join(' ')}');
_currentProcess = await Process.start(
executable,
args,
workingDirectory: minerPath,
mode: ProcessStartMode.normal, // 改为 normal 以捕获输出
);
_currentTask = task;
_startTaskMonitor(task);
_startLogCapture();
_logger.info('挖矿已启动 (PID: ${_currentProcess!.pid})');
return true;
} catch (e) {
_logger.severe('启动挖矿失败: $e');
return false;
}
}
/// 停止挖矿
Future<void> stopMining() async {
_taskMonitor?.cancel();
_taskMonitor = null;
if (_currentProcess != null) {
try {
_currentProcess!.kill();
await _currentProcess!.exitCode;
_logger.info('挖矿已停止');
} catch (e) {
_logger.severe('停止挖矿失败: $e');
} finally {
_currentProcess = null;
_currentTask = null;
}
}
}
/// 获取挖矿软件路径
String? _getMinerPath(String miner, MiningConfig config) {
switch (miner.toLowerCase()) {
case 'lolminer':
return config.lolMinerPath;
case 'rigel':
return config.rigelPath;
case 'bzminer':
return config.bzMinerPath;
default:
return null;
}
}
/// 构建挖矿软件参数
List<String> _buildMinerArgs(MiningTaskInfo task, MiningConfig config) {
final address = task.walletMining ? task.walletAddress : (task.poolUser ?? task.walletAddress);
switch (task.miner.toLowerCase()) {
case 'lolminer':
return [
'--algo', task.coin,
'--pool', task.poolUrl,
'--user', '$address.${task.workerId}',
];
case 'rigel':
return [
'--no-watchdog',
'-a', task.algo.toLowerCase(),
'-o', task.poolUrl,
'-u', address,
'-w', task.workerId,
'--log-file', 'logs/miner.log',
];
case 'bzminer':
return [
'-a', task.coin,
'-w', '$address.${task.workerId}',
'-p', task.poolUrl,
];
default:
return [];
}
}
/// 启动任务监控
void _startTaskMonitor(MiningTaskInfo task) {
_taskMonitor?.cancel();
final endTime = DateTime.fromMillisecondsSinceEpoch(task.endTimestamp * 1000);
final now = DateTime.now();
if (endTime.isBefore(now)) {
// 任务已过期,立即停止
stopMining();
return;
}
final duration = endTime.difference(now);
_taskMonitor = Timer(duration, () {
_logger.info('挖矿任务已到期,自动停止');
stopMining();
});
}
/// 开始捕获挖矿进程输出
void _startLogCapture() {
if (_currentProcess == null) return;
_currentProcess!.stdout
.transform(const SystemEncoding().decoder)
.transform(const LineSplitter())
.listen((line) {
_minerLogController.add('[${DateTime.now().toString().substring(11, 19)}] $line');
});
_currentProcess!.stderr
.transform(const SystemEncoding().decoder)
.transform(const LineSplitter())
.listen((line) {
_minerLogController.add('[${DateTime.now().toString().substring(11, 19)}] [ERROR] $line');
});
}
/// 获取挖矿日志流
Stream<String> get minerLogStream => _minerLogController.stream;
MiningTaskInfo? get currentTask => _currentTask;
bool get isMining => _currentProcess != null;
/// 清理资源
void dispose() {
_minerLogController.close();
}
}
class MiningConfig {
final String? lolMinerPath;
final String? rigelPath;
final String? bzMinerPath;
final bool proxyEnabled;
final String? serverUrl;
final String? updateUrl;
MiningConfig({
this.lolMinerPath,
this.rigelPath,
this.bzMinerPath,
this.proxyEnabled = false,
this.serverUrl,
this.updateUrl,
});
}

View File

@@ -0,0 +1,40 @@
class MiningTaskInfo {
final String coin;
final String algo;
final String pool;
final String poolUrl;
final String walletAddress;
final String workerId;
final String? poolUser;
final bool walletMining;
final int endTimestamp;
final String miner; // 从配置或请求中获取
MiningTaskInfo({
required this.coin,
required this.algo,
required this.pool,
required this.poolUrl,
required this.walletAddress,
required this.workerId,
this.poolUser,
required this.walletMining,
required this.endTimestamp,
required this.miner,
});
factory MiningTaskInfo.fromJson(Map<String, dynamic> json) {
return MiningTaskInfo(
coin: json['coin'] as String,
algo: json['algo'] as String,
pool: json['pool'] as String,
poolUrl: json['pool_url'] as String,
walletAddress: json['wallet_address'] as String,
workerId: json['worker_id'] as String,
poolUser: json['pool_user'] as String?,
walletMining: json['wallet_mining'] as bool? ?? true,
endTimestamp: json['end_timestamp'] as int,
miner: json['miner'] as String? ?? 'lolminer', // 默认值
);
}
}

232
lib/core/sustain_miner.dart Normal file
View File

@@ -0,0 +1,232 @@
import 'dart:async';
import 'dart:io';
import 'package:logging/logging.dart';
// import 'package:ini/ini.dart';
import 'mining_manager.dart';
import 'mining_task_info.dart';
import '../utils/ini_utils.dart';
import '../utils/path_utils.dart';
/// 持续挖矿管理器
class SustainMiner {
static final SustainMiner _instance = SustainMiner._internal();
factory SustainMiner() => _instance;
SustainMiner._internal();
final Logger _logger = Logger('SustainMiner');
final MiningManager _miningManager = MiningManager();
SustainMiningConfig? _config;
MiningConfig? _miningConfig;
Timer? _monitorTimer;
bool _isRunning = false;
bool _isPaused = false;
/// 加载配置
Future<bool> loadConfig(MiningConfig miningConfig) async {
try {
_miningConfig = miningConfig;
final configFile = File(PathUtils.binFile('mining.windows.conf'));
if (!await configFile.exists()) {
_logger.warning('配置文件不存在');
return false;
}
final content = await configFile.readAsString();
final config = parseIni(content);
// ini 2.x: 使用 get(section, option)
final enabledStr = (config.get('sustain', 'enabled') ?? '').toLowerCase();
final enabled = enabledStr == 'true';
if (!enabled) {
_logger.info('持续挖矿未启用');
return false;
}
_config = SustainMiningConfig(
enabled: enabled,
algo: _trimQuotes(config.get('sustain', 'algo') ?? ''),
coin: _trimQuotes(config.get('sustain', 'coin') ?? ''),
miner: _trimQuotes(config.get('sustain', 'miner') ?? ''),
poolUrl: _trimQuotes(config.get('sustain', 'pool_url') ?? ''),
wallet: _trimQuotes(config.get('sustain', 'wallet') ?? ''),
workerId: _trimQuotes(config.get('sustain', 'worker_id') ?? ''),
poolUser: _trimQuotes(config.get('sustain', 'pool_user') ?? ''),
walletMining: (config.get('sustain', 'wallet_mining') ?? '').toLowerCase() == 'true',
);
// 验证配置
if (_config!.algo.isEmpty ||
_config!.coin.isEmpty ||
_config!.miner.isEmpty ||
_config!.poolUrl.isEmpty ||
_config!.wallet.isEmpty ||
_config!.workerId.isEmpty) {
_logger.severe('持续挖矿配置不完整');
return false;
}
// 验证挖矿软件路径
final minerPath = _getMinerPath(_config!.miner);
if (minerPath == null || minerPath.isEmpty) {
_logger.severe('${_config!.miner} 路径未配置');
return false;
}
_logger.info('持续挖矿配置加载成功: 算法=${_config!.algo}, 币种=${_config!.coin}, 挖矿软件=${_config!.miner}');
return true;
} catch (e) {
_logger.severe('加载持续挖矿配置失败: $e');
return false;
}
}
/// 启动持续挖矿
Future<void> start() async {
if (_config == null || !_config!.enabled) {
return;
}
if (_isRunning) {
_logger.info('持续挖矿已在运行中');
return;
}
if (_miningManager.isMining) {
_logger.info('当前有挖矿任务,等待任务结束后启动持续挖矿');
return;
}
_isRunning = true;
_isPaused = false;
_logger.info('启动持续挖矿...');
await _startMining();
}
/// 停止持续挖矿
Future<void> stop() async {
_isRunning = false;
_monitorTimer?.cancel();
_monitorTimer = null;
if (_miningManager.isMining && _miningManager.currentTask == null) {
// 只有持续挖矿任务在运行时才停止
await _miningManager.stopMining();
}
_logger.info('持续挖矿已停止');
}
/// 暂停持续挖矿
Future<void> pause() async {
if (!_isRunning || _isPaused) {
return;
}
_logger.info('暂停持续挖矿(有新任务)...');
_isPaused = true;
if (_miningManager.isMining && _miningManager.currentTask == null) {
await _miningManager.stopMining();
}
}
/// 恢复持续挖矿
Future<void> resume() async {
if (!_isRunning || !_isPaused) {
return;
}
if (_miningManager.isMining) {
_logger.info('当前有挖矿任务,等待任务结束后恢复持续挖矿');
return;
}
_logger.info('恢复持续挖矿...');
_isPaused = false;
await _startMining();
}
/// 启动挖矿
Future<void> _startMining() async {
if (_config == null || _isPaused || _miningConfig == null) {
return;
}
if (_miningManager.isMining) {
return;
}
final task = MiningTaskInfo(
coin: _config!.coin,
algo: _config!.algo,
pool: '',
poolUrl: _config!.poolUrl,
walletAddress: _config!.wallet,
workerId: _config!.workerId,
poolUser: _config!.poolUser,
walletMining: _config!.walletMining,
endTimestamp: DateTime.now().add(const Duration(days: 365)).millisecondsSinceEpoch ~/ 1000, // 持续挖矿设置很长的结束时间
miner: _config!.miner,
);
await _miningManager.startMining(task, _miningConfig!);
}
String _trimQuotes(String value) {
var v = value.trim();
if (v.length >= 2) {
final first = v[0];
final last = v[v.length - 1];
if ((first == '"' && last == '"') || (first == "'" && last == "'")) {
v = v.substring(1, v.length - 1);
}
}
return v;
}
String? _getMinerPath(String miner) {
if (_miningConfig == null) return null;
switch (miner.toLowerCase()) {
case 'lolminer':
return _miningConfig!.lolMinerPath;
case 'rigel':
return _miningConfig!.rigelPath;
case 'bzminer':
return _miningConfig!.bzMinerPath;
default:
return null;
}
}
bool get isRunning => _isRunning;
bool get isPaused => _isPaused;
}
class SustainMiningConfig {
final bool enabled;
final String algo;
final String coin;
final String miner;
final String poolUrl;
final String wallet;
final String workerId;
final String poolUser;
final bool walletMining;
SustainMiningConfig({
required this.enabled,
required this.algo,
required this.coin,
required this.miner,
required this.poolUrl,
required this.wallet,
required this.workerId,
required this.poolUser,
required this.walletMining,
});
}

198
lib/core/system_info.dart Normal file
View File

@@ -0,0 +1,198 @@
import 'dart:io';
import 'package:logging/logging.dart';
import '../models/client_status.dart';
import '../utils/path_utils.dart';
/// 系统信息获取服务
class SystemInfoService {
static final SystemInfoService _instance = SystemInfoService._internal();
factory SystemInfoService() => _instance;
SystemInfoService._internal();
final Logger _logger = Logger('SystemInfoService');
/// 获取机器码(使用硬盘序列号)
Future<String> getMachineCode() async {
try {
if (Platform.isWindows) {
// 延迟执行,避免启动时崩溃
await Future.delayed(const Duration(seconds: 3));
// 使用 wmic 获取硬盘序列号
try {
final result = await Process.run(
'wmic',
['diskdrive', 'get', 'serialnumber'],
runInShell: true,
);
if (result.exitCode == 0) {
final output = result.stdout.toString();
final lines = output.split('\n');
// 查找序列号(跳过标题行)
for (final line in lines) {
final trimmed = line.trim();
if (trimmed.isNotEmpty &&
!trimmed.toLowerCase().contains('serialnumber') &&
trimmed != '') {
// 提取序列号(去除空格)
final serial = trimmed.replaceAll(RegExp(r'\s+'), '');
if (serial.isNotEmpty) {
_logger.info('获取到硬盘序列号: $serial');
return serial;
}
}
}
}
_logger.warning('wmic 命令执行成功但未找到有效序列号');
} catch (e) {
_logger.warning('使用 wmic 获取硬盘序列号失败: $e');
}
// 备用方案:使用环境变量生成标识符
final computerName = Platform.environment['COMPUTERNAME'] ?? 'UNKNOWN';
final userName = Platform.environment['USERNAME'] ?? 'UNKNOWN';
final userProfile = Platform.environment['USERPROFILE'] ?? '';
final identifier = '${computerName}_${userName}_${userProfile.hashCode.abs()}';
_logger.info('使用备用标识符作为机器码: $identifier');
return identifier;
}
// 非 Windows 系统
_logger.warning('无法获取机器码,使用临时标识');
return 'UNKNOWN_${DateTime.now().millisecondsSinceEpoch}';
} catch (e) {
_logger.severe('获取机器码失败: $e');
// 不抛出异常,返回临时标识
return 'ERROR_${DateTime.now().millisecondsSinceEpoch}';
}
}
/// 获取GPU信息仅在启动时调用一次
Future<List<GPUInfo>> getGPUInfo() async {
try {
// 使用 nvidia-smi 获取GPU信息
final result = await Process.run(
'nvidia-smi',
['--query-gpu=index,name,memory.total', '--format=csv,noheader,nounits'],
runInShell: true,
).timeout(
const Duration(seconds: 10),
onTimeout: () {
_logger.warning('nvidia-smi 执行超时');
return ProcessResult(0, -1, '', 'timeout');
},
);
if (result.exitCode != 0) {
_logger.warning('nvidia-smi 执行失败: exitCode=${result.exitCode}, stderr=${result.stderr}');
return [];
}
final output = result.stdout.toString().trim();
if (output.isEmpty) {
_logger.info('nvidia-smi 返回空输出未检测到GPU');
return [];
}
final lines = output.split('\n');
final gpus = <GPUInfo>[];
for (final line in lines) {
if (line.trim().isEmpty) continue;
final parts = line.split(', ');
if (parts.length >= 3) {
try {
final index = int.parse(parts[0].trim());
final model = parts[1].trim();
final memoryStr = parts[2].trim();
final memory = double.tryParse(memoryStr) ?? 0;
gpus.add(GPUInfo(
index: index,
brand: 'NVIDIA',
model: model,
memory: memory,
));
_logger.info('检测到GPU: 索引=$index, 型号=$model, 显存=${memory}MB');
} catch (e) {
_logger.warning('解析GPU信息失败: $line, 错误: $e');
}
}
}
if (gpus.isEmpty) {
_logger.info('未检测到GPU');
} else {
_logger.info('成功获取 ${gpus.length} 个GPU信息');
}
return gpus;
} catch (e) {
_logger.warning('获取GPU信息失败: $e');
// 不抛出异常返回空列表UI会显示"未检测到GPU"
return [];
}
}
/// 读取版本号(从根目录 bin/version 文件)
Future<String> getVersion() async {
try {
final versionPath = PathUtils.binFile('version');
final file = File(versionPath);
if (await file.exists()) {
final content = await file.readAsString();
return content.trim();
} else {
_logger.warning('版本文件不存在: $versionPath');
}
} catch (e) {
_logger.warning('读取版本号失败: $e');
}
return '未知版本';
}
/// 读取身份信息(从根目录 bin/auth 文件)
Future<String> getAuth() async {
try {
final authPath = PathUtils.binFile('auth');
final file = File(authPath);
if (await file.exists()) {
final content = await file.readAsString();
return content.trim();
} else {
_logger.warning('身份文件不存在: $authPath');
}
} catch (e) {
_logger.warning('读取身份信息失败: $e');
}
return '未配置';
}
/// 检查管理员权限Windows
Future<bool> checkAdminPermission() async {
if (!Platform.isWindows) {
return true;
}
try {
// 由于 Process.run 会导致崩溃,这里使用环境变量判断
// 如果存在管理员相关的环境变量,可能具有管理员权限
// 这是一个简化的判断,可能不准确,但不会崩溃
final userProfile = Platform.environment['USERPROFILE'];
if (userProfile != null && userProfile.contains('Administrator')) {
return true;
}
// 默认返回 false表示可能没有管理员权限
_logger.info('无法准确检测管理员权限(避免 Process.run 崩溃),返回 false');
return false;
} catch (e) {
_logger.warning('检查管理员权限失败: $e');
return false;
}
}
}

41
lib/main.dart Normal file
View File

@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
// import 'package:logging/logging.dart';
import 'screens/main_screen.dart';
import 'services/log_service.dart';
import 'services/config_service.dart';
import 'providers/client_provider.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// 初始化日志系统
final logService = LogService();
await logService.initialize();
runApp(const CloudClientApp());
}
class CloudClientApp extends StatelessWidget {
const CloudClientApp({super.key});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => ClientProvider()),
Provider(create: (_) => LogService()),
Provider(create: (_) => ConfigService()),
],
child: MaterialApp(
title: '云算力平台客户端',
theme: ThemeData(
primarySwatch: Colors.blue,
useMaterial3: true,
),
home: const MainScreen(),
debugShowCheckedModeBanner: false,
),
);
}
}

View File

@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
enum ClientStatus {
offline, // 离线(心跳异常,红色)
online, // 在线(心跳正常,绿色)
mining, // 挖矿中(挖矿程序启动时开启,挖矿程序结束后关闭,黄色)
}
extension ClientStatusExtension on ClientStatus {
String get displayName {
switch (this) {
case ClientStatus.offline:
return '离线';
case ClientStatus.online:
return '在线';
case ClientStatus.mining:
return '挖矿中';
}
}
Color get color {
switch (this) {
case ClientStatus.offline:
return Colors.red;
case ClientStatus.online:
return Colors.green;
case ClientStatus.mining:
return Colors.orange;
}
}
}
class ClientInfo {
final String version;
final String auth;
final List<GPUInfo> gpus;
final String machineCode;
final ClientStatus status;
final MiningInfo? miningInfo;
ClientInfo({
required this.version,
required this.auth,
required this.gpus,
required this.machineCode,
required this.status,
this.miningInfo,
});
}
class GPUInfo {
final int index;
final String brand;
final String model;
final double? memory; // MB
GPUInfo({
required this.index,
required this.brand,
required this.model,
this.memory,
});
String get displayName => '$brand $model${memory != null ? ' (${(memory! / 1024).toStringAsFixed(1)} GB)' : ''}';
Map<String, dynamic> toJson() => {
'index': index,
'brand': brand,
'model': model,
'mem': memory,
};
}
class MiningInfo {
final String coin;
final String algo;
final String pool;
final String poolUrl;
final String walletAddress;
final String workerId;
final int? pid;
final String? miner;
final int endTimestamp;
MiningInfo({
required this.coin,
required this.algo,
required this.pool,
required this.poolUrl,
required this.walletAddress,
required this.workerId,
this.pid,
this.miner,
required this.endTimestamp,
});
}

View File

@@ -0,0 +1,408 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import '../core/client_core.dart';
import '../core/system_info.dart';
import '../core/mining_manager.dart';
import '../core/sustain_miner.dart';
import '../core/database.dart';
import '../core/mining_task_info.dart';
import '../services/config_service.dart';
import '../services/update_service.dart';
import '../models/client_status.dart';
/// 客户端状态管理 Provider
class ClientProvider with ChangeNotifier {
final Logger _logger = Logger('ClientProvider');
final ClientCore _clientCore = ClientCore();
final SystemInfoService _systemInfo = SystemInfoService();
final MiningManager _miningManager = MiningManager();
final SustainMiner _sustainMiner = SustainMiner();
final DatabaseService _database = DatabaseService();
final ConfigService _configService = ConfigService();
final UpdateService _updateService = UpdateService();
Timer? _refreshTimer;
ClientInfo? _clientInfo;
bool _isInitialized = false;
bool _isLoading = false;
MiningTaskInfo? _currentMiningTask;
// 服务器地址(从配置文件读取)
String? _serverUrl;
// 版本信息
String? _remoteVersion;
bool _hasUpdate = false;
ClientProvider() {
_initialize();
}
/// 初始化客户端
Future<void> _initialize() async {
_isLoading = true;
notifyListeners();
try {
// 初始化数据库
await _database.initialize();
// 加载系统信息(先加载简单的文件读取,延迟执行 Process 调用)
final version = await _systemInfo.getVersion();
final auth = await _systemInfo.getAuth();
// 检查权限
final hasPermission = await _systemInfo.checkAdminPermission();
if (!hasPermission) {
_logger.warning('未检测到管理员权限');
}
// 加载挖矿配置
final miningConfig = await _configService.parseMiningConfig();
// 从配置文件读取服务器地址
_serverUrl = miningConfig?.serverUrl ?? '18.183.240.108:2345';
// 检查版本更新(如果有配置 update_url
if (miningConfig?.updateUrl != null) {
_checkForUpdates(miningConfig!.updateUrl!, version);
}
// 获取机器码和GPU信息延迟执行避免启动时崩溃
// 只在启动时获取一次,如果失败就显示空列表
String machineCode = '正在获取...';
List<GPUInfo> gpus = [];
try {
// 延迟3秒后获取避免启动时崩溃
await Future.delayed(const Duration(seconds: 3));
_logger.info('开始获取系统信息...');
machineCode = await _systemInfo.getMachineCode();
gpus = await _systemInfo.getGPUInfo();
_logger.info('系统信息获取完成: MAC=$machineCode, GPUs=${gpus.length}');
} catch (e) {
_logger.warning('获取系统信息失败: $e');
// 获取失败时使用默认值
machineCode = '获取失败';
gpus = [];
}
// 准备GPU信息转换为服务器需要的格式
final gpusMap = <String, dynamic>{};
for (var gpu in gpus) {
gpusMap[gpu.index.toString()] = gpu.toJson();
}
// 获取支持的挖矿软件列表
final miningSofts = _getMiningSofts(miningConfig);
// 初始化客户端核心(先连接,但不发送身份认证)
final initialized = await _clientCore.initialize(
serverUrl: _serverUrl ?? '18.183.240.108:2345',
auth: auth,
machineCode: machineCode, // 此时可能还是"正在获取...",需要等待真实值
gpusInfo: gpusMap,
miningSofts: miningSofts,
);
if (!initialized) {
_logger.severe('客户端初始化失败');
// 即使初始化失败,也创建 ClientInfo 以便显示基本信息
_clientInfo = ClientInfo(
version: version,
auth: auth,
gpus: gpus,
machineCode: machineCode,
status: ClientStatus.offline,
miningInfo: null,
);
_isInitialized = true;
_isLoading = false;
notifyListeners();
return;
}
// 设置回调
_clientCore.onStatusChanged = _onStatusChanged;
_clientCore.onMiningTaskChanged = _onMiningTaskChanged;
// 等待机器码获取完成后,发送身份认证消息(使用 身份信息::硬盘身份码 格式)
if (machineCode != '正在获取...' && machineCode != '获取失败') {
// 更新客户端核心的机器码
_clientCore.updateMachineCode(machineCode, gpusMap, miningSofts);
// 发送身份认证消息
_clientCore.sendMachineCode();
}
// 恢复未完成的挖矿任务
final unfinishedTask = await _database.loadUnfinishedTask();
if (unfinishedTask != null) {
if (miningConfig != null) {
await _miningManager.startMining(unfinishedTask, miningConfig);
_currentMiningTask = unfinishedTask;
}
}
// 加载持续挖矿配置并启动(恢复任务之后)
if (miningConfig != null) {
final loaded = await _sustainMiner.loadConfig(miningConfig);
if (loaded && _currentMiningTask == null) {
await _sustainMiner.start();
}
}
// 更新客户端信息
_clientInfo = ClientInfo(
version: version,
auth: auth,
gpus: gpus.map((gpu) => GPUInfo(
index: gpu.index,
brand: gpu.brand,
model: gpu.model,
memory: gpu.memory,
)).toList(),
machineCode: machineCode,
status: ClientStatus.online,
miningInfo: _currentMiningTask != null
? MiningInfo(
coin: _currentMiningTask!.coin,
algo: _currentMiningTask!.algo,
pool: _currentMiningTask!.pool,
poolUrl: _currentMiningTask!.poolUrl,
walletAddress: _currentMiningTask!.walletAddress,
workerId: _currentMiningTask!.workerId,
pid: null, // Dart 进程管理可能需要额外处理
miner: _currentMiningTask!.miner,
endTimestamp: _currentMiningTask!.endTimestamp,
)
: null,
);
_isInitialized = true;
_startAutoRefresh();
} catch (e, stackTrace) {
_logger.severe('初始化失败: $e', e, stackTrace);
// 即使初始化失败,也创建基本的 ClientInfo 以便显示错误信息
try {
final version = await _systemInfo.getVersion();
final auth = await _systemInfo.getAuth();
_clientInfo = ClientInfo(
version: version,
auth: auth,
gpus: [],
machineCode: '初始化失败',
status: ClientStatus.offline,
miningInfo: null,
);
} catch (e2) {
_logger.severe('创建基本 ClientInfo 也失败: $e2');
// 如果连基本信息都获取不到,至少创建一个空的信息对象
_clientInfo = ClientInfo(
version: '未知',
auth: '未知',
gpus: [],
machineCode: '错误',
status: ClientStatus.offline,
miningInfo: null,
);
}
} finally {
_isLoading = false;
notifyListeners();
}
}
/// 状态变化回调
void _onStatusChanged(ClientStatus status) {
if (_clientInfo != null) {
final newStatus = status == ClientStatus.mining || _miningManager.isMining
? ClientStatus.mining
: status;
_clientInfo = ClientInfo(
version: _clientInfo!.version,
auth: _clientInfo!.auth,
gpus: _clientInfo!.gpus,
machineCode: _clientInfo!.machineCode,
status: newStatus,
miningInfo: _currentMiningTask != null
? MiningInfo(
coin: _currentMiningTask!.coin,
algo: _currentMiningTask!.algo,
pool: _currentMiningTask!.pool,
poolUrl: _currentMiningTask!.poolUrl,
walletAddress: _currentMiningTask!.walletAddress,
workerId: _currentMiningTask!.workerId,
pid: null,
miner: _currentMiningTask!.miner,
endTimestamp: _currentMiningTask!.endTimestamp,
)
: null,
);
notifyListeners();
}
}
/// 挖矿任务变化回调
void _onMiningTaskChanged(MiningTaskInfo? task) async {
_currentMiningTask = task;
if (task != null) {
// 暂停持续挖矿
await _sustainMiner.pause();
// 启动挖矿
final miningConfig = await _configService.parseMiningConfig();
if (miningConfig != null) {
await _miningManager.startMining(task, miningConfig);
await _database.insertMiningTask(task);
}
} else {
// 停止挖矿
await _miningManager.stopMining();
// 恢复持续挖矿
await _sustainMiner.resume();
}
_onStatusChanged(_clientCore.isConnected ? ClientStatus.online : ClientStatus.offline);
}
/// 开始自动刷新
void _startAutoRefresh() {
_refreshTimer?.cancel();
_refreshTimer = Timer.periodic(const Duration(seconds: 5), (_) {
refresh();
});
}
/// 手动刷新不重新获取GPU信息只更新状态
Future<void> refresh() async {
if (!_isInitialized || _clientInfo == null) return;
try {
// 只更新状态不重新获取GPU信息GPU信息只在启动时获取一次
final status = _miningManager.isMining
? ClientStatus.mining
: (_clientCore.isConnected ? ClientStatus.online : ClientStatus.offline);
_clientInfo = ClientInfo(
version: _clientInfo!.version,
auth: _clientInfo!.auth,
gpus: _clientInfo!.gpus, // 使用已有的GPU信息不重新获取
machineCode: _clientInfo!.machineCode,
status: status,
miningInfo: _currentMiningTask != null
? MiningInfo(
coin: _currentMiningTask!.coin,
algo: _currentMiningTask!.algo,
pool: _currentMiningTask!.pool,
poolUrl: _currentMiningTask!.poolUrl,
walletAddress: _currentMiningTask!.walletAddress,
workerId: _currentMiningTask!.workerId,
pid: null,
miner: _currentMiningTask!.miner,
endTimestamp: _currentMiningTask!.endTimestamp,
)
: null,
);
notifyListeners();
} catch (e) {
_logger.warning('刷新失败: $e');
}
}
/// 重启客户端
Future<void> restart() async {
_clientCore.stop();
await _miningManager.stopMining();
await _sustainMiner.stop();
_isInitialized = false;
await _initialize();
}
/// 获取支持的挖矿软件列表(辅助方法)
List<String> _getMiningSofts(MiningConfig? miningConfig) {
final miningSofts = <String>[];
if (miningConfig?.lolMinerPath != null && miningConfig!.lolMinerPath!.isNotEmpty) {
miningSofts.add('lolminer');
}
if (miningConfig?.rigelPath != null && miningConfig!.rigelPath!.isNotEmpty) {
miningSofts.add('rigel');
}
if (miningConfig?.bzMinerPath != null && miningConfig!.bzMinerPath!.isNotEmpty) {
miningSofts.add('bzminer');
}
return miningSofts;
}
/// 获取日志流
Stream<String> get logStream => _clientCore.logStream;
ClientInfo? get clientInfo => _clientInfo;
bool get isLoading => _isLoading;
bool get isInitialized => _isInitialized;
bool get hasUpdate => _hasUpdate;
String? get remoteVersion => _remoteVersion;
/// 检查版本更新
Future<void> _checkForUpdates(String updateUrl, String localVersion) async {
try {
final remoteInfo = await _updateService.checkVersion(updateUrl);
if (remoteInfo != null) {
_remoteVersion = remoteInfo.version;
_hasUpdate = _updateService.isNewerVersion(remoteInfo.version, localVersion);
if (_hasUpdate) {
_logger.info('发现新版本: $localVersion -> ${remoteInfo.version}');
}
notifyListeners();
}
} catch (e) {
_logger.warning('检查更新失败: $e');
}
}
/// 执行更新
Future<bool> performUpdate() async {
if (!_hasUpdate || _remoteVersion == null) {
return false;
}
try {
final miningConfig = await _configService.parseMiningConfig();
if (miningConfig?.updateUrl == null) {
_logger.warning('更新URL未配置');
return false;
}
// 获取下载URL
final remoteInfo = await _updateService.checkVersion(miningConfig!.updateUrl!);
if (remoteInfo == null || remoteInfo.downloadUrl.isEmpty) {
_logger.warning('无法获取下载URL');
return false;
}
// 下载并更新
final success = await _updateService.downloadAndUpdate(remoteInfo.downloadUrl);
if (success) {
_logger.info('更新下载完成,将在下次启动时应用');
}
return success;
} catch (e) {
_logger.severe('执行更新失败: $e');
return false;
}
}
@override
void dispose() {
_refreshTimer?.cancel();
_clientCore.stop();
_database.close();
super.dispose();
}
}

View File

@@ -0,0 +1,392 @@
import 'package:flutter/material.dart';
import '../services/config_service.dart';
import '../utils/ini_utils.dart';
class ConfigEditorScreen extends StatefulWidget {
const ConfigEditorScreen({super.key});
@override
State<ConfigEditorScreen> createState() => _ConfigEditorScreenState();
}
class _ConfigEditorScreenState extends State<ConfigEditorScreen> {
final ConfigService _configService = ConfigService();
bool _isLoading = false;
bool _isModified = false;
// 配置项数据
final Map<String, Map<String, TextEditingController>> _controllers = {};
final Map<String, Map<String, bool>> _enabledFlags = {};
final List<String> _sections = [];
@override
void initState() {
super.initState();
_loadConfig();
}
Future<void> _loadConfig() async {
setState(() {
_isLoading = true;
});
try {
final content = await _configService.readConfig();
if (content.isNotEmpty) {
_parseConfig(content);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('加载配置失败: $e')),
);
}
} finally {
setState(() {
_isLoading = false;
});
}
}
void _parseConfig(String content) {
final config = parseIni(content);
_controllers.clear();
_enabledFlags.clear();
_sections.clear();
// 定义所有可能的配置项
final configStructure = {
'client': ['server_url', 'update_url'],
'lolminer': ['path'],
'rigel': ['path'],
'bzminer': ['path'],
'proxy': ['proxy'],
'sustain': ['enabled', 'algo', 'coin', 'miner', 'pool_url', 'wallet', 'worker_id', 'pool_user', 'wallet_mining'],
};
for (final section in configStructure.keys) {
_sections.add(section);
_controllers[section] = {};
_enabledFlags[section] = {};
for (final option in configStructure[section]!) {
final value = config.get(section, option) ?? '';
_controllers[section]![option] = TextEditingController(text: value);
_controllers[section]![option]!.addListener(() {
setState(() {
_isModified = true;
});
});
// 默认所有配置项都是禁用的(需要勾选才能编辑)
_enabledFlags[section]![option] = false;
}
}
}
Future<void> _saveConfig() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('确认保存'),
content: const Text('确定要保存配置文件吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('确定'),
),
],
),
);
if (confirmed == true) {
setState(() {
_isLoading = true;
});
try {
final content = _generateConfigContent();
final success = await _configService.saveConfig(content);
if (mounted) {
setState(() {
_isLoading = false;
_isModified = !success;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(success ? '保存成功' : '保存失败'),
backgroundColor: success ? Colors.green : Colors.red,
),
);
}
} catch (e) {
if (mounted) {
setState(() {
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('保存失败: $e')),
);
}
}
}
}
String _generateConfigContent() {
final buffer = StringBuffer();
// 添加注释
buffer.writeln('#请确认您的主机上安装了下列挖矿软件,确认后可以打开注释,并修改其路径,如果没有安装,请勿打开注释');
buffer.writeln('#请使用双\\\\,否则可能无法解析出准确的路径');
buffer.writeln();
for (final section in _sections) {
if (section == 'client') {
buffer.writeln('[client]');
_writeOption(buffer, section, 'server_url');
_writeOption(buffer, section, 'update_url');
buffer.writeln();
buffer.writeln('#请确认您的主机上安装了下列挖矿软件,确认后可以打开注释,并修改其路径,如果没有安装,请勿打开注释');
buffer.writeln('#请使用双\\\\,否则可能无法解析出准确的路径');
} else if (section == 'lolminer' || section == 'rigel' || section == 'bzminer') {
buffer.writeln('[$section]');
_writeOption(buffer, section, 'path', commentPrefix: '# ');
} else if (section == 'proxy') {
buffer.writeln();
buffer.writeln('#如果您的网络无法直接连通各个矿池需要使用各大矿池专用网络请打开proxy的注释');
buffer.writeln('#打开此注释后会使用各大矿池的专用网络每笔订单额外增加1%的网络费用');
buffer.writeln('[proxy]');
_writeOption(buffer, section, 'proxy', commentPrefix: '# ');
} else if (section == 'sustain') {
buffer.writeln();
buffer.writeln('#持续挖矿开关,即在矿机没有租约期间是否自行挖矿');
buffer.writeln('#开启此选项启动客户端后客户端会自动根据下面配置开启挖矿任务直到云算力平台有人租赁本台GPU主机');
buffer.writeln('#当该租约结束后本台GPU主机会自动切回下方配置的挖矿任务');
buffer.writeln('[sustain]');
_writeOption(buffer, section, 'enabled', commentPrefix: '#');
_writeOption(buffer, section, 'algo', commentPrefix: '#');
_writeOption(buffer, section, 'coin', commentPrefix: '#');
_writeOption(buffer, section, 'miner', commentPrefix: '#');
_writeOption(buffer, section, 'pool_url', commentPrefix: '#');
_writeOption(buffer, section, 'wallet', commentPrefix: '#');
_writeOption(buffer, section, 'worker_id', commentPrefix: '#');
_writeOption(buffer, section, 'pool_user', commentPrefix: '#');
_writeOption(buffer, section, 'wallet_mining', commentPrefix: '#');
}
buffer.writeln();
}
return buffer.toString();
}
void _writeOption(StringBuffer buffer, String section, String option, {String commentPrefix = ''}) {
final controller = _controllers[section]?[option];
final enabled = _enabledFlags[section]?[option] ?? false;
final value = controller?.text ?? '';
if (value.isEmpty || !enabled) {
// 如果值为空或未启用,使用注释形式
if (option == 'path') {
buffer.writeln('$commentPrefix$option=C:\\\\path\\\\${section}');
} else if (option == 'proxy') {
buffer.writeln('$commentPrefix$option=true');
} else if (option == 'enabled') {
buffer.writeln('$commentPrefix$option=true');
} else if (option == 'algo') {
buffer.writeln('$commentPrefix$option="算法"');
} else if (option == 'coin') {
buffer.writeln('$commentPrefix$option="币种"');
} else if (option == 'miner') {
buffer.writeln('$commentPrefix$option="挖矿软件名此处使用的挖矿软件要使用上方已经配置路径的挖矿软件名即bzminer/lolminer/rigel只能填一个自行选择"');
} else if (option == 'pool_url') {
buffer.writeln('$commentPrefix$option="挖矿地址"');
} else if (option == 'wallet') {
buffer.writeln('$commentPrefix$option="挖矿钱包"');
} else if (option == 'worker_id') {
buffer.writeln('$commentPrefix$option="矿工号"');
} else if (option == 'pool_user') {
buffer.writeln('$commentPrefix$option="挖矿账号名f2pool/m2pool等不支持钱包挖矿的矿池需配置其余支持钱包挖矿的矿池无需配置"');
} else if (option == 'wallet_mining') {
buffer.writeln('$commentPrefix$option=true #pool_user打开时同时打开本配置');
} else {
buffer.writeln('$commentPrefix$option=$value');
}
} else {
// 如果值不为空且已启用,写入实际值
if (option == 'path' || option == 'server_url' || option == 'update_url' ||
option == 'pool_url' || option == 'wallet' || option == 'worker_id' ||
option == 'pool_user' || option == 'algo' || option == 'coin' || option == 'miner') {
buffer.writeln('$option=$value');
} else {
buffer.writeln('$option=$value');
}
}
}
@override
void dispose() {
for (final section in _controllers.values) {
for (final controller in section.values) {
controller.dispose();
}
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('配置文件编辑'),
actions: [
if (_isModified)
IconButton(
icon: const Icon(Icons.save),
onPressed: _isLoading ? null : _saveConfig,
tooltip: '保存',
),
],
),
body: _isLoading && _controllers.isEmpty
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_isModified)
Container(
padding: const EdgeInsets.all(12.0),
margin: const EdgeInsets.only(bottom: 16.0),
decoration: BoxDecoration(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(8.0),
border: Border.all(color: Colors.orange),
),
child: Row(
children: [
Icon(Icons.info, color: Colors.orange.shade700),
const SizedBox(width: 8),
const Expanded(
child: Text(
'配置已修改,请点击保存按钮保存更改',
style: TextStyle(fontWeight: FontWeight.w500),
),
),
],
),
),
..._sections.map((section) => _buildSection(section)),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _isLoading || !_isModified ? null : _saveConfig,
icon: const Icon(Icons.save),
label: const Text('保存配置'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
),
],
),
),
);
}
Widget _buildSection(String section) {
final sectionNames = {
'client': '客户端配置',
'lolminer': 'LolMiner 配置',
'rigel': 'Rigel 配置',
'bzminer': 'BzMiner 配置',
'proxy': '代理配置',
'sustain': '持续挖矿配置',
};
return Card(
margin: const EdgeInsets.only(bottom: 16.0),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
sectionNames[section] ?? section,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const Divider(),
...(_controllers[section]?.keys.map((option) => _buildConfigField(section, option)) ?? []),
],
),
),
);
}
Widget _buildConfigField(String section, String option) {
final controller = _controllers[section]?[option];
final enabled = _enabledFlags[section]?[option] ?? false;
final fieldNames = {
'server_url': '服务器地址',
'update_url': '更新服务器地址',
'path': '软件路径',
'proxy': '启用代理',
'enabled': '启用持续挖矿',
'algo': '算法',
'coin': '币种',
'miner': '挖矿软件',
'pool_url': '矿池地址',
'wallet': '钱包地址',
'worker_id': '矿工号',
'pool_user': '矿池用户名',
'wallet_mining': '钱包挖矿',
};
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Checkbox(
value: enabled,
onChanged: (value) {
setState(() {
_enabledFlags[section]![option] = value ?? false;
_isModified = true;
});
},
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
fieldNames[option] ?? option,
style: const TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
TextField(
controller: controller,
enabled: enabled,
decoration: InputDecoration(
border: const OutlineInputBorder(),
hintText: '请输入${fieldNames[option] ?? option}',
filled: !enabled,
fillColor: enabled ? null : Colors.grey.shade200,
),
),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/client_provider.dart';
import '../services/log_service.dart';
import 'dart:async';
class LogViewerScreen extends StatefulWidget {
const LogViewerScreen({super.key});
@override
State<LogViewerScreen> createState() => _LogViewerScreenState();
}
class _LogViewerScreenState extends State<LogViewerScreen> {
final LogService _logService = LogService();
final ScrollController _scrollController = ScrollController();
String _logs = '';
StreamSubscription<String>? _logSubscription;
StreamSubscription<String>? _clientLogSubscription;
bool _autoScroll = true;
@override
void initState() {
super.initState();
_loadLogs();
_startMonitoring();
}
Future<void> _loadLogs() async {
final logs = await _logService.getRecentLogs();
setState(() {
_logs = logs;
});
if (_autoScroll && _scrollController.hasClients) {
_scrollToBottom();
}
}
void _startMonitoring() {
// 监控日志文件
_logSubscription = _logService.logStream.listen((log) {
setState(() {
_logs += '\n$log';
});
if (_autoScroll && _scrollController.hasClients) {
_scrollToBottom();
}
});
// 监控客户端日志流
final clientProvider = Provider.of<ClientProvider>(context, listen: false);
_clientLogSubscription = clientProvider.logStream.listen((log) {
setState(() {
_logs += '\n$log';
});
if (_autoScroll && _scrollController.hasClients) {
_scrollToBottom();
}
});
}
void _scrollToBottom() {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
}
@override
void dispose() {
_logSubscription?.cancel();
_clientLogSubscription?.cancel();
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('日志查看'),
actions: [
IconButton(
icon: Icon(_autoScroll ? Icons.arrow_downward : Icons.arrow_upward),
onPressed: () {
setState(() {
_autoScroll = !_autoScroll;
});
},
tooltip: _autoScroll ? '关闭自动滚动' : '开启自动滚动',
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadLogs,
tooltip: '刷新日志',
),
IconButton(
icon: const Icon(Icons.clear),
onPressed: () async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('确认清理'),
content: const Text('确定要清理所有日志吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('确定'),
),
],
),
);
if (confirmed == true) {
await _logService.clearLogs();
_loadLogs();
}
},
tooltip: '清理日志',
),
],
),
body: Container(
padding: const EdgeInsets.all(8.0),
child: SingleChildScrollView(
controller: _scrollController,
child: SelectableText(
_logs.isEmpty ? '暂无日志' : _logs,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
),
),
);
}
}

View File

@@ -0,0 +1,426 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/client_provider.dart';
import '../models/client_status.dart';
import 'log_viewer_screen.dart';
import 'config_editor_screen.dart';
import 'mining_info_screen.dart';
import 'dart:io';
class MainScreen extends StatelessWidget {
const MainScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('云算力平台客户端'),
centerTitle: true,
),
body: Consumer<ClientProvider>(
builder: (context, provider, child) {
final clientInfo = provider.clientInfo;
if (provider.isLoading && clientInfo == null) {
return const Center(child: CircularProgressIndicator());
}
if (clientInfo == null) {
return const Center(child: Text('无法获取客户端信息'));
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 状态指示器
_buildStatusCard(clientInfo),
const SizedBox(height: 16),
// 基本信息卡片
_buildInfoCard(clientInfo),
const SizedBox(height: 16),
// 版本更新提示
if (provider.hasUpdate)
_buildUpdateCard(context, provider),
if (provider.hasUpdate) const SizedBox(height: 16),
// GPU信息卡片
_buildGPUCard(clientInfo.gpus),
const SizedBox(height: 16),
// 操作按钮
_buildActionButtons(context, clientInfo),
],
),
);
},
),
);
}
Widget _buildStatusCard(ClientInfo clientInfo) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: clientInfo.status.color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 12),
Text(
'当前状态: ${clientInfo.status.displayName}',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
),
);
}
Widget _buildInfoCard(ClientInfo clientInfo) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'基本信息',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const Divider(),
_buildInfoRow('版本号', clientInfo.version),
_buildInfoRow('身份信息', clientInfo.auth),
_buildInfoRow('机器码', clientInfo.machineCode),
],
),
),
);
}
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 100,
child: Text(
'$label:',
style: const TextStyle(fontWeight: FontWeight.w500),
),
),
Expanded(
child: Text(value),
),
],
),
);
}
Widget _buildGPUCard(List<GPUInfo> gpus) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'GPU信息',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const Divider(),
if (gpus.isEmpty)
const Text('未检测到GPU')
else
...gpus.map((gpu) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Text('GPU ${gpu.index}: ${gpu.displayName}'),
)),
],
),
),
);
}
Widget _buildUpdateCard(BuildContext context, ClientProvider provider) {
return Card(
color: Colors.orange.shade50,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.system_update, color: Colors.orange.shade700),
const SizedBox(width: 8),
const Text(
'发现新版本',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.orange,
),
),
],
),
const SizedBox(height: 8),
Text('当前版本: ${provider.clientInfo?.version ?? '未知'}'),
Text('最新版本: ${provider.remoteVersion ?? '未知'}'),
const SizedBox(height: 12),
ElevatedButton.icon(
onPressed: () => _performUpdate(context, provider),
icon: const Icon(Icons.download),
label: const Text('立即更新'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
),
),
],
),
),
);
}
Future<void> _performUpdate(BuildContext context, ClientProvider provider) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('确认更新'),
content: const Text(
'更新将在下载完成后,下次启动时应用。\n'
'更新过程中请勿关闭程序。\n\n'
'确定要继续吗?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('确定'),
),
],
),
);
if (confirmed == true) {
// 显示进度对话框
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('正在下载更新...'),
],
),
),
);
final success = await provider.performUpdate();
if (context.mounted) {
Navigator.pop(context); // 关闭进度对话框
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(success ? '更新成功' : '更新失败'),
content: Text(
success
? '更新已下载完成,将在下次启动时应用。\n请重启程序以完成更新。'
: '更新下载失败,请检查网络连接或稍后重试。',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('确定'),
),
],
),
);
}
}
}
Widget _buildActionButtons(BuildContext context, ClientInfo clientInfo) {
return Wrap(
spacing: 12,
runSpacing: 12,
children: [
ElevatedButton.icon(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const LogViewerScreen()),
);
},
icon: const Icon(Icons.description),
label: const Text('查看日志'),
),
ElevatedButton.icon(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const ConfigEditorScreen()),
);
},
icon: const Icon(Icons.settings),
label: const Text('查看/修改配置'),
),
ElevatedButton.icon(
onPressed: clientInfo.status == ClientStatus.mining && clientInfo.miningInfo != null
? () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => MiningInfoScreen(miningInfo: clientInfo.miningInfo!),
),
);
}
: null,
icon: const Icon(Icons.diamond),
label: const Text('挖矿信息'),
style: ElevatedButton.styleFrom(
backgroundColor: clientInfo.status == ClientStatus.mining && clientInfo.miningInfo != null
? null
: Colors.grey,
),
),
ElevatedButton.icon(
onPressed: () => _restartClient(context),
icon: const Icon(Icons.refresh),
label: const Text('重启'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
),
),
ElevatedButton.icon(
onPressed: () => _exitApp(context),
icon: const Icon(Icons.exit_to_app),
label: const Text('退出程序'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
),
],
);
}
void _restartClient(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('确认重启'),
content: const Text('确定要重启客户端吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('确定'),
),
],
),
);
if (confirmed == true) {
// 显示加载对话框,禁用所有操作
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => PopScope(
canPop: false, // 禁止返回
child: const AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('正在重启客户端...'),
],
),
),
),
);
final provider = Provider.of<ClientProvider>(context, listen: false);
bool success = false;
String errorMessage = '';
try {
await provider.restart();
success = true;
} catch (e) {
errorMessage = e.toString();
}
if (context.mounted) {
Navigator.pop(context); // 关闭加载对话框
// 显示结果
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(success ? '客户端重启成功' : '客户端重启失败: $errorMessage'),
backgroundColor: success ? Colors.green : Colors.red,
duration: const Duration(seconds: 3),
),
);
}
}
}
void _exitApp(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('确认退出'),
content: const Text('确定要退出程序吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('确定'),
),
],
),
);
if (confirmed == true) {
exit(0);
}
}
}

View File

@@ -0,0 +1,239 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'dart:async';
import '../models/client_status.dart';
import '../core/mining_manager.dart';
class MiningInfoScreen extends StatefulWidget {
final MiningInfo miningInfo;
const MiningInfoScreen({
super.key,
required this.miningInfo,
});
@override
State<MiningInfoScreen> createState() => _MiningInfoScreenState();
}
class _MiningInfoScreenState extends State<MiningInfoScreen> {
final MiningManager _miningManager = MiningManager();
final ScrollController _logScrollController = ScrollController();
final List<String> _minerLogs = [];
StreamSubscription<String>? _logSubscription;
bool _autoScroll = true;
@override
void initState() {
super.initState();
_startLogMonitoring();
}
void _startLogMonitoring() {
_logSubscription = _miningManager.minerLogStream.listen((log) {
setState(() {
_minerLogs.add(log);
// 限制日志行数,避免内存溢出
if (_minerLogs.length > 1000) {
_minerLogs.removeAt(0);
}
});
if (_autoScroll && _logScrollController.hasClients) {
_scrollToBottom();
}
});
}
void _scrollToBottom() {
if (_logScrollController.hasClients) {
_logScrollController.animateTo(
_logScrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
}
@override
void dispose() {
_logSubscription?.cancel();
_logScrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final endTime = DateTime.fromMillisecondsSinceEpoch(widget.miningInfo.endTimestamp * 1000);
final formatter = DateFormat('yyyy-MM-dd HH:mm:ss');
return Scaffold(
appBar: AppBar(
title: const Text('挖矿信息'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 挖矿任务信息卡片
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'当前挖矿任务',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const Divider(),
_buildInfoRow('币种', widget.miningInfo.coin),
_buildInfoRow('算法', widget.miningInfo.algo),
_buildInfoRow('矿池', widget.miningInfo.pool),
_buildInfoRow('矿池地址', widget.miningInfo.poolUrl),
_buildInfoRow('钱包地址', widget.miningInfo.walletAddress),
_buildInfoRow('矿工号', widget.miningInfo.workerId),
if (widget.miningInfo.miner != null)
_buildInfoRow('挖矿软件', widget.miningInfo.miner!),
if (widget.miningInfo.pid != null)
_buildInfoRow('进程ID', widget.miningInfo.pid.toString()),
_buildInfoRow(
'结束时间',
formatter.format(endTime),
),
const SizedBox(height: 16),
_buildTimeRemaining(endTime),
],
),
),
),
const SizedBox(height: 16),
// 挖矿日志卡片
Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
const Text(
'挖矿日志',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
IconButton(
icon: Icon(_autoScroll ? Icons.arrow_downward : Icons.arrow_upward),
onPressed: () {
setState(() {
_autoScroll = !_autoScroll;
});
},
tooltip: _autoScroll ? '关闭自动滚动' : '开启自动滚动',
),
IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
setState(() {
_minerLogs.clear();
});
},
tooltip: '清空日志',
),
],
),
),
const Divider(height: 1),
Container(
height: 400,
padding: const EdgeInsets.all(8.0),
child: _minerLogs.isEmpty
? const Center(
child: Text(
'暂无日志输出',
style: TextStyle(color: Colors.grey),
),
)
: SingleChildScrollView(
controller: _logScrollController,
child: SelectableText(
_minerLogs.join('\n'),
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
),
),
],
),
),
],
),
),
);
}
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 100,
child: Text(
'$label:',
style: const TextStyle(fontWeight: FontWeight.w500),
),
),
Expanded(
child: Text(value),
),
],
),
);
}
Widget _buildTimeRemaining(DateTime endTime) {
final now = DateTime.now();
final remaining = endTime.difference(now);
if (remaining.isNegative) {
return const Card(
color: Colors.red,
child: Padding(
padding: EdgeInsets.all(8.0),
child: Text(
'任务已结束',
style: TextStyle(color: Colors.white),
),
),
);
}
final hours = remaining.inHours;
final minutes = remaining.inMinutes.remainder(60);
final seconds = remaining.inSeconds.remainder(60);
return Card(
color: Colors.green,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'剩余时间: ${hours}小时 ${minutes}分钟 ${seconds}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
);
}
}

View File

@@ -0,0 +1,67 @@
import 'dart:io';
// import 'package:ini/ini.dart';
import '../core/mining_manager.dart';
import '../utils/ini_utils.dart';
import '../utils/path_utils.dart';
class ConfigService {
String get _configFile => PathUtils.binFile('mining.windows.conf');
/// 读取配置文件内容
Future<String> readConfig() async {
try {
final file = File(_configFile);
if (await file.exists()) {
return await file.readAsString();
}
} catch (e) {
// 静默失败,返回空字符串
}
return '';
}
/// 保存配置文件
Future<bool> saveConfig(String content) async {
try {
final file = File(_configFile);
await file.writeAsString(content);
return true;
} catch (e) {
return false;
}
}
/// 解析配置文件并返回 MiningConfig
Future<MiningConfig?> parseMiningConfig() async {
try {
final content = await readConfig();
if (content.isEmpty) {
return null;
}
final config = parseIni(content);
// ini 2.x: 通过 get(section, option) 访问键值
final lolMinerPath = (config.get('lolminer', 'path') ?? '').trim();
final rigelPath = (config.get('rigel', 'path') ?? '').trim();
final bzMinerPath = (config.get('bzminer', 'path') ?? '').trim();
final proxyEnabled = (config.get('proxy', 'proxy') ?? '').toLowerCase() == 'true';
// 读取服务器和更新URL
final serverUrl = (config.get('client', 'server_url') ?? '').trim();
final updateUrl = (config.get('client', 'update_url') ?? '').trim();
return MiningConfig(
lolMinerPath: lolMinerPath.isEmpty ? null : lolMinerPath,
rigelPath: rigelPath.isEmpty ? null : rigelPath,
bzMinerPath: bzMinerPath.isEmpty ? null : bzMinerPath,
proxyEnabled: proxyEnabled,
serverUrl: serverUrl.isEmpty ? null : serverUrl,
updateUrl: updateUrl.isEmpty ? null : updateUrl,
);
} catch (e) {
// 静默失败,返回 null
return null;
}
}
}

View File

@@ -0,0 +1,91 @@
import 'dart:async';
import 'dart:io';
import 'package:logging/logging.dart';
import '../utils/path_utils.dart';
/// 日志服务
class LogService {
static final LogService _instance = LogService._internal();
factory LogService() => _instance;
LogService._internal();
final Logger _rootLogger = Logger.root;
final StreamController<String> _logController = StreamController<String>.broadcast();
IOSink? _logFile;
Stream<String> get logStream => _logController.stream;
/// 初始化日志系统
Future<void> initialize() async {
// 配置日志输出
_rootLogger.level = Level.ALL;
_rootLogger.onRecord.listen((record) {
final message = '[${record.time}] [${record.level.name}] ${record.message}';
_logController.add(message);
_logToFile(message);
});
// 打开日志文件
try {
final logDir = Directory(PathUtils.binFile('logs'));
if (!await logDir.exists()) {
await logDir.create(recursive: true);
}
final logFile = File(PathUtils.binFile('logs/client.log'));
_logFile = logFile.openWrite(mode: FileMode.append);
} catch (e) {
// 静默失败
}
}
/// 写入日志文件
void _logToFile(String message) {
try {
if (_logFile != null) {
_logFile!.writeln(message);
_logFile!.flush();
}
} catch (e) {
// 忽略写入错误,避免崩溃
// print('写入日志文件失败: $e');
}
}
/// 获取最新日志(限制行数)
Future<String> getRecentLogs({int maxLines = 1000}) async {
try {
final file = File(PathUtils.binFile('logs/client.log'));
if (await file.exists()) {
final lines = await file.readAsLines();
if (lines.length > maxLines) {
return lines.sublist(lines.length - maxLines).join('\n');
}
return lines.join('\n');
}
} catch (e) {
// 静默失败
}
return '';
}
/// 清理日志
Future<void> clearLogs() async {
try {
final file = File(PathUtils.binFile('logs/client.log'));
if (await file.exists()) {
await file.writeAsString('');
_logController.add('日志已清理');
}
} catch (e) {
// 静默失败
}
}
/// 关闭日志服务
Future<void> close() async {
await _logFile?.flush();
await _logFile?.close();
await _logController.close();
}
}

View File

@@ -0,0 +1,154 @@
import 'dart:io';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
import 'package:http/http.dart' as http;
/// 更新服务 - 处理版本检查和客户端更新
class UpdateService {
static final UpdateService _instance = UpdateService._internal();
factory UpdateService() => _instance;
UpdateService._internal();
final Logger _logger = Logger('UpdateService');
/// 检查远程版本信息
Future<RemoteVersionInfo?> checkVersion(String updateUrl) async {
try {
_logger.info('检查远程版本: $updateUrl');
// 构建版本检查URL: update_url/user/getClientVersion
final versionUrl = updateUrl.endsWith('/')
? '${updateUrl}user/getClientVersion'
: '$updateUrl/user/getClientVersion';
final response = await http.get(Uri.parse(versionUrl)).timeout(
const Duration(seconds: 10),
);
if (response.statusCode == 200) {
// 返回的是纯文本版本号不是JSON
final remoteVersion = response.body.trim();
if (remoteVersion.isEmpty) {
_logger.warning('远程版本号为空');
return null;
}
_logger.info('获取到远程版本号: $remoteVersion');
// 注意这里只返回版本号下载URL需要从其他地方获取
// 可以根据需要扩展 RemoteVersionInfo 或使用其他方式获取下载URL
return RemoteVersionInfo(
version: remoteVersion,
downloadUrl: '', // 需要从其他接口获取或使用默认URL
releaseNotes: null,
);
} else {
_logger.warning('获取版本信息失败: HTTP ${response.statusCode}');
}
} catch (e) {
_logger.warning('检查版本失败: $e');
}
return null;
}
/// 比较版本号(简单字符串比较,可以改进为语义化版本比较)
bool isNewerVersion(String remoteVersion, String localVersion) {
// 简单的字符串比较,如果不同则认为远程版本更新
// 可以改进为语义化版本比较(如 1.2.3 vs 1.2.4
return remoteVersion != localVersion && remoteVersion.isNotEmpty;
}
/// 下载并更新客户端
Future<bool> downloadAndUpdate(String downloadUrl) async {
try {
_logger.info('开始下载新版本: $downloadUrl');
// 获取当前可执行文件路径
final currentExe = Platform.resolvedExecutable;
final exeDir = p.dirname(currentExe);
final exeName = p.basename(currentExe);
// 下载文件保存为临时文件
final tempFile = p.join(exeDir, '${exeName}.new');
final backupFile = p.join(exeDir, '${exeName}.backup');
// 下载文件
final response = await http.get(Uri.parse(downloadUrl)).timeout(
const Duration(minutes: 10),
);
if (response.statusCode != 200) {
_logger.severe('下载失败: HTTP ${response.statusCode}');
return false;
}
// 保存到临时文件
final file = File(tempFile);
await file.writeAsBytes(response.bodyBytes);
_logger.info('文件下载完成: $tempFile');
// 备份当前文件
final currentFile = File(currentExe);
if (await currentFile.exists()) {
await currentFile.copy(backupFile);
_logger.info('已备份当前文件: $backupFile');
}
// 替换文件(需要关闭当前进程)
// 注意:在 Windows 上,不能直接覆盖正在运行的可执行文件
// 需要使用批处理脚本在下次启动时替换
await _createUpdateScript(exeDir, exeName, tempFile, backupFile);
_logger.info('更新脚本已创建,将在下次启动时应用更新');
return true;
} catch (e) {
_logger.severe('下载更新失败: $e');
return false;
}
}
/// 创建更新脚本Windows批处理文件
Future<void> _createUpdateScript(
String exeDir,
String exeName,
String tempFile,
String backupFile,
) async {
final scriptPath = p.join(exeDir, 'update.bat');
final script = '''
@echo off
timeout /t 2 /nobreak >nul
copy /Y "$tempFile" "$exeDir\\$exeName"
if %ERRORLEVEL% EQU 0 (
del "$tempFile"
echo 更新成功
) else (
copy /Y "$backupFile" "$exeDir\\$exeName"
echo 更新失败,已恢复备份
del "$tempFile"
)
del "$backupFile"
del "%~f0"
''';
final scriptFile = File(scriptPath);
await scriptFile.writeAsString(script);
// 执行脚本(延迟执行,以便当前进程退出)
Process.start('cmd', ['/c', 'start', '/min', scriptPath], runInShell: true);
}
}
/// 远程版本信息
class RemoteVersionInfo {
final String version;
final String downloadUrl;
final String? releaseNotes;
RemoteVersionInfo({
required this.version,
required this.downloadUrl,
this.releaseNotes,
});
}

8
lib/utils/ini_utils.dart Normal file
View File

@@ -0,0 +1,8 @@
import 'package:ini/ini.dart';
/// 解析 INI 字符串为 Config
Config parseIni(String content) {
// 对于 ini 2.xfromString 接受完整字符串
return Config.fromString(content);
}

117
lib/utils/path_utils.dart Normal file
View File

@@ -0,0 +1,117 @@
import 'dart:io';
import 'package:path/path.dart' as p;
/// 路径工具类 - 获取应用根目录和 bin 目录
class PathUtils {
static String? _cachedAppRoot;
/// 获取应用根目录
/// 在发布模式下,返回 .exe 文件所在目录
/// 在开发模式下,返回 windows 目录(因为 bin 文件夹在 windows/bin 下)
static String get appRoot {
if (_cachedAppRoot != null) {
return _cachedAppRoot!;
}
try {
// 尝试获取可执行文件所在目录
final exePath = Platform.resolvedExecutable;
final exeDir = p.dirname(exePath);
// 检查是否是开发模式(可执行文件在 build 目录下)
if (exeDir.contains('build') || exeDir.contains('windows\\build') || exeDir.contains('windows/build')) {
// 开发模式bin 文件夹在 windows/bin 下
// 从可执行文件路径向上查找 windows 目录
var currentPath = exeDir;
var foundWindows = false;
// 向上查找,直到找到包含 'windows' 且不是 'build\windows' 的目录
while (currentPath.isNotEmpty) {
final dirName = p.basename(currentPath);
final parent = p.dirname(currentPath);
// 如果当前目录名是 'windows',且不在 build 路径中,说明找到了项目根目录下的 windows 目录
if (dirName == 'windows' && !currentPath.contains('build\\windows') && !currentPath.contains('build/windows')) {
// 检查这个 windows 目录下是否有 bin 文件夹
final binPath = p.join(currentPath, 'bin');
if (Directory(binPath).existsSync()) {
_cachedAppRoot = currentPath;
foundWindows = true;
break;
}
}
if (parent == currentPath) break; // 到达根目录
currentPath = parent;
}
if (foundWindows) {
return _cachedAppRoot!;
}
// 如果向上查找失败,尝试从当前工作目录查找
final currentDir = Directory.current.path;
if (currentDir.contains('windows')) {
// 找到 windows 目录
final parts = currentDir.split(RegExp(r'[\\/]'));
var windowsIndex = -1;
for (int i = 0; i < parts.length; i++) {
if (parts[i] == 'windows') {
windowsIndex = i;
break;
}
}
if (windowsIndex >= 0) {
final windowsDir = parts.sublist(0, windowsIndex + 1).join(Platform.pathSeparator);
final binPath = p.join(windowsDir, 'bin');
if (Directory(binPath).existsSync()) {
_cachedAppRoot = windowsDir;
return _cachedAppRoot!;
}
}
}
}
// 发布模式:可执行文件和 bin 文件夹在同一目录
_cachedAppRoot = exeDir;
return _cachedAppRoot!;
} catch (e) {
// 如果获取失败,尝试使用当前工作目录
final currentDir = Directory.current.path;
if (currentDir.contains('windows')) {
// 找到 windows 目录
final parts = currentDir.split(RegExp(r'[\\/]'));
var windowsIndex = -1;
for (int i = 0; i < parts.length; i++) {
if (parts[i] == 'windows') {
windowsIndex = i;
break;
}
}
if (windowsIndex >= 0) {
final windowsDir = parts.sublist(0, windowsIndex + 1).join(Platform.pathSeparator);
final binPath = p.join(windowsDir, 'bin');
if (Directory(binPath).existsSync()) {
_cachedAppRoot = windowsDir;
return _cachedAppRoot!;
}
}
}
_cachedAppRoot = currentDir;
return _cachedAppRoot!;
}
}
/// 获取 bin 目录路径
static String get binDir {
final root = appRoot;
return p.join(root, 'bin');
}
/// 获取 bin 目录下的文件路径
static String binFile(String filename) {
return p.join(binDir, filename);
}
}