云算力平台windows桌面应用
This commit is contained in:
349
lib/core/client_core.dart
Normal file
349
lib/core/client_core.dart
Normal 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
185
lib/core/database.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
214
lib/core/mining_manager.dart
Normal file
214
lib/core/mining_manager.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
40
lib/core/mining_task_info.dart
Normal file
40
lib/core/mining_task_info.dart
Normal 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
232
lib/core/sustain_miner.dart
Normal 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
198
lib/core/system_info.dart
Normal 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
41
lib/main.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
96
lib/models/client_status.dart
Normal file
96
lib/models/client_status.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
408
lib/providers/client_provider.dart
Normal file
408
lib/providers/client_provider.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
392
lib/screens/config_editor_screen.dart
Normal file
392
lib/screens/config_editor_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
145
lib/screens/log_viewer_screen.dart
Normal file
145
lib/screens/log_viewer_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
426
lib/screens/main_screen.dart
Normal file
426
lib/screens/main_screen.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
239
lib/screens/mining_info_screen.dart
Normal file
239
lib/screens/mining_info_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
67
lib/services/config_service.dart
Normal file
67
lib/services/config_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
91
lib/services/log_service.dart
Normal file
91
lib/services/log_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
154
lib/services/update_service.dart
Normal file
154
lib/services/update_service.dart
Normal 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
8
lib/utils/ini_utils.dart
Normal file
@@ -0,0 +1,8 @@
|
||||
import 'package:ini/ini.dart';
|
||||
|
||||
/// 解析 INI 字符串为 Config
|
||||
Config parseIni(String content) {
|
||||
// 对于 ini 2.x,fromString 接受完整字符串
|
||||
return Config.fromString(content);
|
||||
}
|
||||
|
||||
117
lib/utils/path_utils.dart
Normal file
117
lib/utils/path_utils.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user