This commit is contained in:
lzx
2026-01-23 16:11:20 +08:00
parent e4e0e44555
commit efce651809
10 changed files with 276 additions and 77 deletions

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"cmake.sourceDirectory": "C:/Users/86133/Desktop/windows/windows"
}

43
UPDATE.md Normal file
View File

@@ -0,0 +1,43 @@
## 更新记录
> 说明:本文件用于记录每次客户端代码层面的更新内容,按时间倒序排列。
---
### 2026-01-23
- **网络与构建相关**
- 配置 Flutter 使用国内镜像源(`PUB_HOSTED_URL` / `FLUTTER_STORAGE_BASE_URL`),解决 `pub.dev` 访问失败问题。
- 清理并修复 Windows 构建缓存CMake 路径不一致导致的构建失败)。
- 排查 `sqlite3` 原生资产构建失败的原因(无法访问 GitHub 下载预编译库),为后续在有外网环境下构建做准备。
- **数据库与路径**
- 移除未使用的 `package:path/path.dart` 导入database 层统一通过 `PathUtils.binFile` 处理路径。
- **重连与认证逻辑**
- `ClientCore`
- 为 TCP 连接新增**指数退避重连策略**10s、20s、40s、80s、160s最多重试 5 次,并在连接成功后重置重试次数。
- 在连接错误、连接关闭和心跳超时(超过 60 分钟未收到 ping时触发重连。
- **重连成功后自动发送 `auth.machineCode` 认证消息**(在已成功获取机器码和身份信息的前提下)。
- **持续挖矿SustainMiner**
-`mining.windows.conf``[sustain]` 段读取配置,校验必填字段与矿工程序路径。
- 当没有租约任务时自动启动持续挖矿,有租约任务到来时暂停,租约结束后自动恢复持续挖矿。
- 新增持续挖矿进程监控:
- 监听矿工进程退出事件如果在持续挖矿运行且未暂停的情况下进程意外退出5 秒后自动重启持续挖矿。
- **状态与 UI 展示**
- `ClientStatus`
- 新增 `sustainingMining` 状态,用于表示“持续挖矿中”,在界面上显示为**蓝色圆点 + 文案「持续挖矿中」**。
- `ClientProvider`
- 根据是否有租约任务 / 持续挖矿任务,区分显示:
- `mining`:租约挖矿中。
- `sustainingMining`:持续挖矿中。
- 统一通过 `_getMiningInfo()` 构造当前挖矿信息(支持租约挖矿和持续挖矿),供界面展示。
- `MainScreen`
- “挖矿信息”按钮在以下状态下可用:`ClientStatus.mining``ClientStatus.sustainingMining`,并且存在 `miningInfo`
- 点击后进入同一套挖矿信息界面。
- `MiningInfoScreen`
- 复用同一界面展示日志和当前任务信息。
- 当为**持续挖矿**时,不再显示“结束时间”和“剩余时间”区域,仅展示基本任务信息和日志。

View File

@@ -1,20 +1,29 @@
[client]
server_url=10.168.2.220:2345
update_url=http://10.168.2.220:8888/lease
#请确认您的主机上安装了下列挖矿软件,确认后可以打开注释,并修改其路径,如果没有安装,请勿打开注释
#请使用双\\,否则可能无法解析出准确的路径
[bzminer]
# path=C:\\path\\bzminer
[client]
server_url=18.183.240.108:2345
update_url=https://test.m2pool.com/api/lease
#请确认您的主机上安装了下列挖矿软件,确认后可以打开注释,并修改其路径,如果没有安装,请勿打开注释
#请使用双\\,否则可能无法解析出准确的路径
[lolminer]
# path=C:\\path\\lolminer
[rigel]
# path=C:\\path\\rigel
#如果您的网络无法直接连通各个矿池需要使用各大矿池专用网咯请打开proxy的注释
[bzminer]
# path=C:\\path\\bzminer
#如果您的网络无法直接连通各个矿池需要使用各大矿池专用网络请打开proxy的注释
#打开此注释后会使用各大矿池的专用网络每笔订单额外增加1%的网络费用
[proxy]
# proxy=true
#持续挖矿开关,即在矿机没有租约期间是否自行挖矿
#开启此选项启动客户端后客户端会自动根据下面配置开启挖矿任务直到云算力平台有人租赁本台GPU主机
#当该租约结束后本台GPU主机会自动切回下方配置的挖矿任务
@@ -28,3 +37,4 @@ update_url=http://10.168.2.220:8888/lease
#worker_id="矿工号"
#pool_user="挖矿账号名f2pool/m2pool等不支持钱包挖矿的矿池需配置其余支持钱包挖矿的矿池无需配置"
#wallet_mining=true #pool_user打开时同时打开本配置

View File

@@ -20,8 +20,13 @@ class ClientCore {
bool _isConnected = false;
DateTime? _lastPingTime;
Timer? _heartbeatTimer;
Timer? _reconnectTimer;
StreamController<String>? _logController;
// 重连相关
int _reconnectAttempts = 0;
static const int _maxReconnectAttempts = 5;
// 需要GPU和挖矿软件信息用于认证
Map<String, dynamic>? _gpusInfo;
List<String>? _miningSofts;
@@ -86,11 +91,10 @@ class ClientCore {
_socket = await Socket.connect(host, port, timeout: const Duration(seconds: 10));
_isConnected = true;
// 连接成功,重置重连计数器
_reconnectAttempts = 0;
_log('连接到服务器成功: $_serverUrl');
// 注意:不在这里发送身份认证,等待机器码获取完成后再发送
// 身份认证消息将在机器码获取完成后通过 sendMachineCode() 发送
// 开始接收消息
_socket!.listen(
_onDataReceived,
@@ -100,6 +104,12 @@ class ClientCore {
);
onStatusChanged?.call(ui.ClientStatus.online);
// 如果已经有机器码和认证信息,立即发送认证消息(重连场景)
if (_auth != null && _machineCode != null && _machineCode!.isNotEmpty && _machineCode != '正在获取...' && _machineCode != '获取失败') {
_log('重连成功,自动发送身份认证消息');
_sendMachineCode();
}
} catch (e) {
_isConnected = false;
_log('连接失败: $e');
@@ -280,20 +290,54 @@ class ClientCore {
_reconnect();
}
/// 重连
/// 重连(指数退避策略)
void _reconnect() {
Future.delayed(const Duration(seconds: 5), () {
// 取消之前的重连定时器
_reconnectTimer?.cancel();
// 检查是否正在停止或已停止
if (_socket == null || _logController == null) {
return; // 已经停止,不执行重连
}
if (!_isConnected) {
_log('尝试重新连接...');
_connect().catchError((e) {
_log('重连失败: $e');
});
// 检查是否已达到最大重试次数
if (_reconnectAttempts >= _maxReconnectAttempts) {
_log('已达到最大重连次数($_maxReconnectAttempts次),停止重连');
return;
}
// 计算延迟时间10秒 * 2^(重试次数)
// 第1次10秒第2次20秒第3次40秒第4次80秒第5次160秒
final delaySeconds = 10 * (1 << _reconnectAttempts);
_reconnectAttempts++;
_log('将在 ${delaySeconds}秒 后进行第 $_reconnectAttempts 次重连尝试(最多$_maxReconnectAttempts次');
_reconnectTimer = Timer(Duration(seconds: delaySeconds), () {
// 再次检查是否正在停止或已停止
if (_socket == null || _logController == null) {
return; // 已经停止,不执行重连
}
// 检查是否已经连接成功(可能在其他地方已经连接)
if (_isConnected) {
_reconnectAttempts = 0; // 重置计数器
return;
}
_log('尝试第 $_reconnectAttempts 次重新连接...');
_connect().then((_) {
// 连接成功,计数器已在 _connect() 中重置
_log('重连成功');
}).catchError((e) {
_log('$_reconnectAttempts 次重连失败: $e');
// 如果未达到最大重试次数,继续重连
if (_reconnectAttempts < _maxReconnectAttempts) {
_reconnect();
} else {
_log('已达到最大重连次数,停止重连');
}
});
});
}
@@ -307,6 +351,8 @@ class ClientCore {
_log('超过60分钟未收到心跳连接可能已断开');
_isConnected = false;
onStatusChanged?.call(ui.ClientStatus.offline);
// 心跳超时视为新的断开事件,重置重连计数器
_reconnectAttempts = 0;
_reconnect();
}
}
@@ -331,6 +377,10 @@ class ClientCore {
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
_reconnectTimer?.cancel();
_reconnectTimer = null;
_reconnectAttempts = 0; // 重置重连计数器
// 先取消 socket 监听,避免 onDone 回调在关闭 controller 后执行
_socket?.destroy();
_socket = null;

View File

@@ -15,6 +15,10 @@ class MiningManager {
MiningTaskInfo? _currentTask;
Timer? _taskMonitor;
final StreamController<String> _minerLogController = StreamController<String>.broadcast();
final StreamController<void> _processExitController = StreamController<void>.broadcast();
/// 进程退出事件流
Stream<void> get processExitStream => _processExitController.stream;
/// 启动挖矿
Future<bool> startMining(MiningTaskInfo task, MiningConfig config) async {
@@ -71,6 +75,7 @@ class MiningManager {
_currentTask = task;
_startTaskMonitor(task);
_startLogCapture();
_monitorProcessExit();
_logger.info('挖矿已启动 (PID: ${_currentProcess!.pid})');
return true;
@@ -183,6 +188,28 @@ class MiningManager {
});
}
/// 监控进程退出
void _monitorProcessExit() {
if (_currentProcess == null) return;
_currentProcess!.exitCode.then((exitCode) {
_logger.warning('挖矿进程已退出,退出码: $exitCode');
// 清理状态
_currentProcess = null;
final wasMining = _currentTask != null;
_currentTask = null;
_taskMonitor?.cancel();
_taskMonitor = null;
// 发送进程退出事件
if (wasMining) {
_processExitController.add(null);
}
}).catchError((e) {
_logger.severe('监控进程退出失败: $e');
});
}
/// 获取挖矿日志流
Stream<String> get minerLogStream => _minerLogController.stream;
@@ -192,6 +219,7 @@ class MiningManager {
/// 清理资源
void dispose() {
_minerLogController.close();
_processExitController.close();
}
}

View File

@@ -19,6 +19,7 @@ class SustainMiner {
SustainMiningConfig? _config;
MiningConfig? _miningConfig;
Timer? _monitorTimer;
StreamSubscription<void>? _processExitSubscription;
bool _isRunning = false;
bool _isPaused = false;
@@ -104,6 +105,7 @@ class SustainMiner {
_logger.info('启动持续挖矿...');
await _startMining();
_startProcessMonitor();
}
/// 停止持续挖矿
@@ -111,6 +113,8 @@ class SustainMiner {
_isRunning = false;
_monitorTimer?.cancel();
_monitorTimer = null;
_processExitSubscription?.cancel();
_processExitSubscription = null;
if (_miningManager.isMining && _miningManager.currentTask == null) {
// 只有持续挖矿任务在运行时才停止
@@ -148,6 +152,10 @@ class SustainMiner {
_logger.info('恢复持续挖矿...');
_isPaused = false;
await _startMining();
// 确保进程监控已启动
if (_processExitSubscription == null) {
_startProcessMonitor();
}
}
/// 启动挖矿
@@ -176,6 +184,26 @@ class SustainMiner {
await _miningManager.startMining(task, _miningConfig!);
}
/// 启动进程监控
void _startProcessMonitor() {
_processExitSubscription?.cancel();
// 监听挖矿进程退出事件
_processExitSubscription = _miningManager.processExitStream.listen((_) {
// 检查是否是持续挖矿进程退出
if (_isRunning && !_isPaused && !_miningManager.isMining && _miningManager.currentTask == null) {
_logger.warning('持续挖矿进程意外退出将在5秒后自动重启...');
// 延迟5秒后重启避免频繁重启
Future.delayed(const Duration(seconds: 5), () {
if (_isRunning && !_isPaused && !_miningManager.isMining) {
_logger.info('自动重启持续挖矿...');
_startMining();
}
});
}
});
}
String _trimQuotes(String value) {
var v = value.trim();
if (v.length >= 2) {

View File

@@ -3,7 +3,8 @@ import 'package:flutter/material.dart';
enum ClientStatus {
offline, // 离线(心跳异常,红色)
online, // 在线(心跳正常,绿色)
mining, // 挖矿中(挖矿程序启动时开启,挖矿程序结束后关闭,黄色)
mining, // 挖矿中(租约挖矿,黄色)
sustainingMining, // 持续挖矿中(持续挖矿,蓝色)
}
extension ClientStatusExtension on ClientStatus {
@@ -15,6 +16,8 @@ extension ClientStatusExtension on ClientStatus {
return '在线';
case ClientStatus.mining:
return '挖矿中';
case ClientStatus.sustainingMining:
return '持续挖矿中';
}
}
@@ -26,6 +29,8 @@ extension ClientStatusExtension on ClientStatus {
return Colors.green;
case ClientStatus.mining:
return Colors.orange;
case ClientStatus.sustainingMining:
return Colors.blue;
}
}
}

View File

@@ -165,20 +165,8 @@ class ClientProvider with ChangeNotifier {
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,
status: _getInitialStatus(),
miningInfo: _getMiningInfo(),
);
_isInitialized = true;
@@ -218,9 +206,16 @@ class ClientProvider with ChangeNotifier {
/// 状态变化回调
void _onStatusChanged(ClientStatus status) {
if (_clientInfo != null) {
final newStatus = status == ClientStatus.mining || _miningManager.isMining
// 判断当前状态:优先显示挖矿状态
ClientStatus newStatus;
if (_miningManager.isMining) {
// 如果有租约任务,显示挖矿中;否则显示持续挖矿中
newStatus = _currentMiningTask != null
? ClientStatus.mining
: status;
: (_sustainMiner.isRunning ? ClientStatus.sustainingMining : ClientStatus.mining);
} else {
newStatus = status;
}
_clientInfo = ClientInfo(
version: _clientInfo!.version,
@@ -228,19 +223,7 @@ class ClientProvider with ChangeNotifier {
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,
miningInfo: _getMiningInfo(),
);
notifyListeners();
}
@@ -268,6 +251,7 @@ class ClientProvider with ChangeNotifier {
await _sustainMiner.resume();
}
// 更新状态
_onStatusChanged(_clientCore.isConnected ? ClientStatus.online : ClientStatus.offline);
}
@@ -285,9 +269,15 @@ class ClientProvider with ChangeNotifier {
try {
// 只更新状态不重新获取GPU信息GPU信息只在启动时获取一次
final status = _miningManager.isMining
ClientStatus status;
if (_miningManager.isMining) {
// 如果有租约任务,显示挖矿中;否则显示持续挖矿中
status = _currentMiningTask != null
? ClientStatus.mining
: (_clientCore.isConnected ? ClientStatus.online : ClientStatus.offline);
: (_sustainMiner.isRunning ? ClientStatus.sustainingMining : ClientStatus.mining);
} else {
status = _clientCore.isConnected ? ClientStatus.online : ClientStatus.offline;
}
_clientInfo = ClientInfo(
version: _clientInfo!.version,
@@ -295,8 +285,29 @@ class ClientProvider with ChangeNotifier {
gpus: _clientInfo!.gpus, // 使用已有的GPU信息不重新获取
machineCode: _clientInfo!.machineCode,
status: status,
miningInfo: _currentMiningTask != null
? MiningInfo(
miningInfo: _getMiningInfo(),
);
notifyListeners();
} catch (e) {
_logger.warning('刷新失败: $e');
}
}
/// 获取初始状态
ClientStatus _getInitialStatus() {
if (_miningManager.isMining) {
return _currentMiningTask != null
? ClientStatus.mining
: (_sustainMiner.isRunning ? ClientStatus.sustainingMining : ClientStatus.mining);
}
return _clientCore.isConnected ? ClientStatus.online : ClientStatus.offline;
}
/// 获取挖矿信息(支持租约挖矿和持续挖矿)
MiningInfo? _getMiningInfo() {
if (_currentMiningTask != null) {
// 租约挖矿任务
return MiningInfo(
coin: _currentMiningTask!.coin,
algo: _currentMiningTask!.algo,
pool: _currentMiningTask!.pool,
@@ -306,14 +317,26 @@ class ClientProvider with ChangeNotifier {
pid: null,
miner: _currentMiningTask!.miner,
endTimestamp: _currentMiningTask!.endTimestamp,
)
: null,
);
notifyListeners();
} catch (e) {
_logger.warning('刷新失败: $e');
} else if (_miningManager.isMining && _sustainMiner.isRunning) {
// 持续挖矿任务
final sustainTask = _miningManager.currentTask;
if (sustainTask != null) {
return MiningInfo(
coin: sustainTask.coin,
algo: sustainTask.algo,
pool: sustainTask.pool,
poolUrl: sustainTask.poolUrl,
walletAddress: sustainTask.walletAddress,
workerId: sustainTask.workerId,
pid: null,
miner: sustainTask.miner,
endTimestamp: sustainTask.endTimestamp,
);
}
}
return null;
}
/// 重启客户端
Future<void> restart() async {

View File

@@ -295,12 +295,15 @@ class MainScreen extends StatelessWidget {
label: const Text('查看/修改配置'),
),
ElevatedButton.icon(
onPressed: clientInfo.status == ClientStatus.mining && clientInfo.miningInfo != null
onPressed: (clientInfo.status == ClientStatus.mining || clientInfo.status == ClientStatus.sustainingMining) && clientInfo.miningInfo != null
? () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => MiningInfoScreen(miningInfo: clientInfo.miningInfo!),
builder: (_) => MiningInfoScreen(
miningInfo: clientInfo.miningInfo!,
isSustainMining: clientInfo.status == ClientStatus.sustainingMining,
),
),
);
}
@@ -308,7 +311,7 @@ class MainScreen extends StatelessWidget {
icon: const Icon(Icons.diamond),
label: const Text('挖矿信息'),
style: ElevatedButton.styleFrom(
backgroundColor: clientInfo.status == ClientStatus.mining && clientInfo.miningInfo != null
backgroundColor: ((clientInfo.status == ClientStatus.mining || clientInfo.status == ClientStatus.sustainingMining) && clientInfo.miningInfo != null)
? null
: Colors.grey,
),

View File

@@ -6,10 +6,12 @@ import '../core/mining_manager.dart';
class MiningInfoScreen extends StatefulWidget {
final MiningInfo miningInfo;
final bool isSustainMining; // 是否为持续挖矿
const MiningInfoScreen({
super.key,
required this.miningInfo,
this.isSustainMining = false,
});
@override
@@ -63,8 +65,10 @@ class _MiningInfoScreenState extends State<MiningInfoScreen> {
@override
Widget build(BuildContext context) {
final endTime = DateTime.fromMillisecondsSinceEpoch(widget.miningInfo.endTimestamp * 1000);
final formatter = DateFormat('yyyy-MM-dd HH:mm:ss');
final endTime = widget.isSustainMining
? null
: DateTime.fromMillisecondsSinceEpoch(widget.miningInfo.endTimestamp * 1000);
return Scaffold(
appBar: AppBar(
@@ -100,6 +104,7 @@ class _MiningInfoScreenState extends State<MiningInfoScreen> {
_buildInfoRow('挖矿软件', widget.miningInfo.miner!),
if (widget.miningInfo.pid != null)
_buildInfoRow('进程ID', widget.miningInfo.pid.toString()),
if (!widget.isSustainMining && endTime != null) ...[
_buildInfoRow(
'结束时间',
formatter.format(endTime),
@@ -107,6 +112,7 @@ class _MiningInfoScreenState extends State<MiningInfoScreen> {
const SizedBox(height: 16),
_buildTimeRemaining(endTime),
],
],
),
),
),