云算力平台windows桌面应用
This commit is contained in:
45
.gitignore
vendored
Normal file
45
.gitignore
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
/coverage/
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
30
.metadata
Normal file
30
.metadata
Normal file
@@ -0,0 +1,30 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "f6ff1529fd6d8af5f706051d9251ac9231c83407"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
|
||||
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
|
||||
- platform: windows
|
||||
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
|
||||
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
189
README.md
Normal file
189
README.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# 云算力平台客户端 - Windows 桌面应用
|
||||
|
||||
基于 Flutter 开发的 Windows 桌面客户端应用,实现与云算力平台的通信、挖矿管理等功能。
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 1. 主界面信息显示
|
||||
|
||||
主界面实时显示以下信息:
|
||||
|
||||
- **版本号**:从 `bin/version` 文件读取
|
||||
- **身份信息**:从 `bin/auth` 文件读取
|
||||
- **GPU 信息**:通过 `nvidia-smi` 命令自动检测(启动时获取一次)
|
||||
- 显示 GPU 索引、品牌、型号、显存大小
|
||||
- **硬盘身份码**:通过 `wmic diskdrive get serialnumber` 命令获取
|
||||
- **当前状态**:实时显示客户端连接状态
|
||||
- 🔴 **离线**:心跳异常,红色指示
|
||||
- 🟢 **在线**:心跳正常,绿色指示
|
||||
- 🟡 **挖矿中**:挖矿程序运行中,黄色指示
|
||||
|
||||
### 2. 版本更新功能
|
||||
|
||||
- 自动检查远程版本(从配置的 `update_url` 获取)
|
||||
- 发现新版本时显示更新提示卡片
|
||||
- 支持一键下载并更新客户端
|
||||
- 更新完成后在下次启动时自动应用
|
||||
|
||||
### 3. 主要功能按钮
|
||||
|
||||
#### 3.1 查看日志
|
||||
- 实时动态显示客户端日志
|
||||
- 支持自动滚动和手动滚动
|
||||
- 可刷新和清空日志
|
||||
- 日志文件位置:`bin/logs/client.log`
|
||||
|
||||
#### 3.2 查看/修改配置
|
||||
- **表单式配置编辑**:采用输入框形式,而非直接文本编辑
|
||||
- 每个配置项都有复选框,勾选后才能编辑
|
||||
- 按配置节分组显示:
|
||||
- 客户端配置(server_url, update_url)
|
||||
- LolMiner 配置
|
||||
- Rigel 配置
|
||||
- BzMiner 配置
|
||||
- 代理配置
|
||||
- 持续挖矿配置
|
||||
- 配置文件位置:`bin/mining.windows.conf`
|
||||
- 修改后需点击保存按钮保存更改
|
||||
|
||||
#### 3.3 挖矿信息
|
||||
- **按钮状态**:一直显示,但只有在挖矿中状态时才能点击
|
||||
- 显示当前挖矿任务的详细信息:
|
||||
- 币种、算法、矿池、矿池地址
|
||||
- 钱包地址、矿工号
|
||||
- 挖矿软件、进程ID
|
||||
- 任务结束时间、剩余时间
|
||||
- **实时日志显示**:
|
||||
- 显示挖矿软件的标准输出和错误输出
|
||||
- 支持自动滚动和手动滚动
|
||||
- 可清空日志
|
||||
- 日志格式与外部查看日志布局相似
|
||||
|
||||
#### 3.4 重启
|
||||
- 点击后显示确认对话框
|
||||
- 重启过程中显示加载动画,禁用所有操作
|
||||
- 重启完成后显示成功/失败结果提示
|
||||
- 自动重新初始化所有服务
|
||||
|
||||
#### 3.5 退出程序
|
||||
- 点击后显示确认对话框
|
||||
- 确认后安全退出应用程序
|
||||
|
||||
## 技术架构
|
||||
|
||||
### 核心模块
|
||||
|
||||
- **ClientCore**:TCP 通信核心,处理与服务器的连接、心跳、消息收发
|
||||
- **MiningManager**:挖矿软件管理,支持 lolminer、rigel、bzminer
|
||||
- **SustainMiner**:持续挖矿管理,在无租约期间自动挖矿
|
||||
- **SystemInfoService**:系统信息获取(GPU、硬盘序列号等)
|
||||
- **ConfigService**:配置文件管理(INI 格式)
|
||||
- **UpdateService**:版本检查和更新管理
|
||||
- **DatabaseService**:SQLite 数据库,存储挖矿任务历史
|
||||
- **LogService**:日志文件管理
|
||||
|
||||
### 数据存储
|
||||
|
||||
- **配置文件**:`bin/mining.windows.conf`(INI 格式)
|
||||
- **身份文件**:`bin/auth`
|
||||
- **版本文件**:`bin/version`
|
||||
- **数据库**:`bin/mining_task.db`(SQLite)
|
||||
- **日志文件**:`bin/logs/client.log`
|
||||
|
||||
### 通信协议
|
||||
|
||||
- **协议**:TCP Socket
|
||||
- **消息格式**:JSON,每行一条消息
|
||||
- **心跳机制**:服务器发送 ping,客户端回复 pong
|
||||
- **身份认证**:格式为 `身份信息::硬盘身份码`
|
||||
|
||||
## 配置文件说明
|
||||
|
||||
配置文件 `bin/mining.windows.conf` 采用 INI 格式,包含以下配置节:
|
||||
|
||||
### [client]
|
||||
- `server_url`:服务器地址(RabbitMQ 通信目标)
|
||||
- `update_url`:更新服务器地址(用于版本检查和更新)
|
||||
|
||||
### [lolminer] / [rigel] / [bzminer]
|
||||
- `path`:挖矿软件路径(使用双反斜杠 `\\`)
|
||||
|
||||
### [proxy]
|
||||
- `proxy`:是否启用代理(true/false)
|
||||
|
||||
### [sustain]
|
||||
- `enabled`:是否启用持续挖矿
|
||||
- `algo`:算法
|
||||
- `coin`:币种
|
||||
- `miner`:挖矿软件名
|
||||
- `pool_url`:矿池地址
|
||||
- `wallet`:钱包地址
|
||||
- `worker_id`:矿工号
|
||||
- `pool_user`:矿池用户名(可选)
|
||||
- `wallet_mining`:是否使用钱包挖矿
|
||||
|
||||
## 部署说明
|
||||
|
||||
### 开发环境
|
||||
|
||||
1. 确保已安装 Flutter SDK 并启用 Windows 桌面支持
|
||||
2. 在 `windows` 目录下运行:
|
||||
```bash
|
||||
flutter pub get
|
||||
flutter run -d windows
|
||||
```
|
||||
|
||||
### 发布版本
|
||||
|
||||
1. 构建发布版本:
|
||||
```bash
|
||||
flutter build windows --release
|
||||
```
|
||||
|
||||
2. 部署文件结构:
|
||||
```
|
||||
cloud_client_gui.exe
|
||||
bin/
|
||||
├── version # 版本文件(必需)
|
||||
├── auth # 身份信息文件(必需)
|
||||
├── mining.windows.conf # 配置文件(必需)
|
||||
├── logs/
|
||||
│ └── client.log # 日志文件(自动创建)
|
||||
└── mining_task.db # 数据库文件(自动创建)
|
||||
```
|
||||
|
||||
3. **重要**:`bin` 文件夹必须与 `.exe` 文件在同一目录下
|
||||
|
||||
## 路径说明
|
||||
|
||||
- **开发模式**:程序会自动检测并定位到 `windows/bin` 目录
|
||||
- **发布模式**:`bin` 文件夹应与可执行文件在同一目录
|
||||
- 所有文件路径都基于可执行文件所在目录自动计算
|
||||
|
||||
## 依赖项
|
||||
|
||||
主要依赖包:
|
||||
- `provider`:状态管理
|
||||
- `sqflite_common_ffi`:SQLite 数据库(Windows 桌面)
|
||||
- `ini`:INI 文件解析
|
||||
- `http`:HTTP 请求(版本更新)
|
||||
- `logging`:日志系统
|
||||
- `path`:路径处理
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 首次运行需要确保 `bin` 目录下存在 `version` 和 `auth` 文件
|
||||
2. GPU 信息只在程序启动时获取一次,避免频繁调用系统命令
|
||||
3. 配置文件修改后需要保存才能生效
|
||||
4. 重启功能会重新初始化所有服务,可能需要几秒钟时间
|
||||
5. 挖矿信息按钮在非挖矿状态下会显示为灰色且不可点击
|
||||
|
||||
## 开发说明
|
||||
|
||||
本项目是基于 Go 客户端功能完全重新实现的 Flutter Windows 桌面应用,实现了与 Go 版本相同的所有核心功能,包括:
|
||||
- TCP 通信和心跳机制
|
||||
- 挖矿软件管理
|
||||
- 持续挖矿支持
|
||||
- 系统信息获取
|
||||
- 配置管理
|
||||
- 版本更新
|
||||
6
analysis_options.yaml
Normal file
6
analysis_options.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
rules:
|
||||
prefer_const_constructors: true
|
||||
prefer_const_literals_to_create_immutables: true
|
||||
1
bin/auth copy
Normal file
1
bin/auth copy
Normal file
@@ -0,0 +1 @@
|
||||
393768033@qq.com
|
||||
26
bin/mining.linux.conf
Normal file
26
bin/mining.linux.conf
Normal file
@@ -0,0 +1,26 @@
|
||||
#请确认您的主机上安装了下列挖矿软件,确认后可以打开注释,并修改其路径,如果没有安装,请勿打开注释
|
||||
[bzminer]
|
||||
# path=/path/bzminer
|
||||
[lolminer]
|
||||
# path=/path/lolminer
|
||||
[rigel]
|
||||
# path=/path/rigel
|
||||
|
||||
#如果您的网络无法直接连通各个矿池,需要使用各大矿池专用网咯,请打开proxy的注释
|
||||
#打开此注释后会使用各大矿池的专用网络,每笔订单额外增加1%的网络费用
|
||||
[proxy]
|
||||
# proxy=true
|
||||
|
||||
#持续挖矿开关,即在矿机没有租约期间是否自行挖矿
|
||||
#开启此选项启动客户端后,客户端会自动根据下面配置开启挖矿任务,直到云算力平台有人租赁本台GPU主机
|
||||
#当该租约结束后,本台GPU主机会自动切回下方配置的挖矿任务
|
||||
[sustain]
|
||||
#enabled=true
|
||||
#algo="算法"
|
||||
#coin="币种"
|
||||
#miner="挖矿软件名,此处使用的挖矿软件要使用上方已经配置路径的挖矿软件名,即bzminer/lolminer/rigel,只能填一个,自行选择"
|
||||
#pool_url="挖矿地址"
|
||||
#wallet="挖矿钱包"
|
||||
#worker_id="矿工号"
|
||||
#pool_user="挖矿账号名,f2pool/m2pool等不支持钱包挖矿的矿池需配置,其余支持钱包挖矿的矿池无需配置"
|
||||
#wallet_mining=true #pool_user打开时同时打开本配置
|
||||
30
bin/mining.windows.conf
Normal file
30
bin/mining.windows.conf
Normal file
@@ -0,0 +1,30 @@
|
||||
[client]
|
||||
server_url=10.168.2.220:2345
|
||||
update_url=http://10.168.2.220:8888/lease
|
||||
#请确认您的主机上安装了下列挖矿软件,确认后可以打开注释,并修改其路径,如果没有安装,请勿打开注释
|
||||
#请使用双\\,否则可能无法解析出准确的路径
|
||||
[bzminer]
|
||||
# path=C:\\path\\bzminer
|
||||
[lolminer]
|
||||
# path=C:\\path\\lolminer
|
||||
[rigel]
|
||||
# path=C:\\path\\rigel
|
||||
|
||||
#如果您的网络无法直接连通各个矿池,需要使用各大矿池专用网咯,请打开proxy的注释
|
||||
#打开此注释后会使用各大矿池的专用网络,每笔订单额外增加1%的网络费用
|
||||
[proxy]
|
||||
# proxy=true
|
||||
|
||||
#持续挖矿开关,即在矿机没有租约期间是否自行挖矿
|
||||
#开启此选项启动客户端后,客户端会自动根据下面配置开启挖矿任务,直到云算力平台有人租赁本台GPU主机
|
||||
#当该租约结束后,本台GPU主机会自动切回下方配置的挖矿任务
|
||||
[sustain]
|
||||
#enabled=true
|
||||
#algo="算法"
|
||||
#coin="币种"
|
||||
#miner="挖矿软件名,此处使用的挖矿软件要使用上方已经配置路径的挖矿软件名,即bzminer/lolminer/rigel,只能填一个,自行选择"
|
||||
#pool_url="挖矿地址"
|
||||
#wallet="挖矿钱包"
|
||||
#worker_id="矿工号"
|
||||
#pool_user="挖矿账号名,f2pool/m2pool等不支持钱包挖矿的矿池需配置,其余支持钱包挖矿的矿池无需配置"
|
||||
#wallet_mining=true #pool_user打开时同时打开本配置
|
||||
BIN
bin/mining_task copy.db
Normal file
BIN
bin/mining_task copy.db
Normal file
Binary file not shown.
BIN
bin/mining_task.db
Normal file
BIN
bin/mining_task.db
Normal file
Binary file not shown.
22
bin/user-manual.txt
Normal file
22
bin/user-manual.txt
Normal file
@@ -0,0 +1,22 @@
|
||||
This program is an automated mining program for cloud computing platforms. After launching this client, the seller no longer needs to manually configure mining according to the buyer's requirements.
|
||||
Please read the following precautions carefully before using this program.
|
||||
Very important:
|
||||
1. Please download the correct system version of the client, Windows or Linux (Ubuntu, etc.), based on the operating system of the host where your GPU is located.
|
||||
2. You must download this client through the official website and under the correct seller account, as it contains your seller information. If you download the client through unofficial channels or the wrong seller account, your GPU information cannot be synchronized to the cloud computing platform.
|
||||
3. After downloading and extracting, please ensure that your client contains the following files:
|
||||
Execute file (client. exe or client)
|
||||
auth
|
||||
mining.linux.conf
|
||||
mining.windows.conf
|
||||
If there are missing files, please contact customer service for Customer-Service.
|
||||
4. If you have multiple GPU hosts, you can download the client compressed file on one host and copy it to other hosts in any way, or download the compressed file on different hosts through step 2.
|
||||
5. Before starting the client, please check that your computer meets the following conditions:
|
||||
1. GPUs can be correctly recognized, which usually requires installing the corresponding driver for the GPU. NVIDIA series graphics cards can view the graphics card information through the nvidia smi command. If the command line prints the graphics card information, it means that the graphics card driver has been installed
|
||||
2. At least one mining software from lolminer, rigel, and bzminer has been installed
|
||||
3. The network is smooth, and different mining pools can be directly connected through the host where the GPU is located for mining
|
||||
We suggest that you conduct mining tests on each mining pool before starting this client. If you can obtain mining tasks, it indicates that the network connection is normal. If your network conditions are poor, it will affect your rental income in the end.
|
||||
|
||||
Other matters:
|
||||
1. Please ensure that the current user who starts the client has sufficient permissions. It is recommended to use the admin user for Windows and the root user for Linux. This client will automatically detect the permissions of relevant files at startup, and if the permissions are insufficient, the client will automatically exit.
|
||||
2. If the GPU cannot be synchronized to the cloud computing platform after starting the client normally, please contact customer service for Customer-Service.
|
||||
3. If your network conditions are not good, you can configure a proxy address on the cloud computing platform yourself. We will automatically forward the buyer's mining requests to the proxy address you have configured. If you have other needs, you can contact our customer service for Customer-Service.
|
||||
1
bin/version
Normal file
1
bin/version
Normal file
@@ -0,0 +1 @@
|
||||
version advanced
|
||||
23
bin/用户手册.txt
Normal file
23
bin/用户手册.txt
Normal file
@@ -0,0 +1,23 @@
|
||||
本程序是云算力平台的自动化挖矿程序,启动本客户端后,卖方不再需要手动针对买方的要求进行手动配置挖矿
|
||||
使用本程序前请您仔细阅读以下注意事项
|
||||
|
||||
非常重要:
|
||||
1,请您根据您的GPU所在主机的操作系统,下载正确系统版本的客户端,windows或linux(ubuntu等)
|
||||
2,必须通过官网且在正确的卖家账号下下载本客户端,因为里面包含您的卖家信息,如果通过非官方渠道或错误的卖家账号下载了客户端,您的GPU信息无法同步到云算力平台
|
||||
3,下载并解压后,请确定您的客户端包含以下文件:
|
||||
执行文件(client_windows.exe或client_linux)
|
||||
auth
|
||||
mining.linux.conf
|
||||
mining.windows.conf
|
||||
如果有文件缺失,请联系客服获得帮助
|
||||
4,如果您有多个GPU主机,可在某一台主机下载客户端压缩包后,将压缩包通过任意方式复制给其他主机,或在不同主机上通过步骤2下载压缩包
|
||||
5,在启动客户端之前,请检查您的电脑具备以下条件:
|
||||
1,GPU可以被正确识别,这通常需要安装GPU对应的驱动,NVIDIA系列显卡可以通过 nvidia-smi 命令查看显卡信息,如果命令行打印了显卡信息,则表示显卡驱动已经安装
|
||||
2,已经安装lolminer、rigel、bzminer中至少一个挖矿软件
|
||||
3,网络通畅,可以直接通过GPU所在主机连接不同的矿池挖矿
|
||||
建议您启动本客户端之前,自行对各个矿池进行挖矿测试,能获取到挖矿任务表示网络连接正常。如果您的网络条件不好,会影响最终您的租赁收益
|
||||
|
||||
其他事项:
|
||||
1,请确保启动客户端的当前用户拥有足够的权限,建议windows使用admin用户,linux使用root用户,本客户端会在启动时自动检测相关文件的权限,如果权限不够,客户端会自动退出
|
||||
2,如果正常启动客户端后未能正常将GPU同步到云算力平台,请联系客服获得帮助
|
||||
3,如果您的网络条件不好,可以在云算力平台自行配置代理地址,我们会自动将买家的挖矿请求自动转发到您配置的代理地址,如果有其他需求,可以联系我们的客服获得帮助
|
||||
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);
|
||||
}
|
||||
}
|
||||
498
pubspec.lock
Normal file
498
pubspec.lock
Normal file
@@ -0,0 +1,498 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: args
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.0"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
charcode:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: charcode
|
||||
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: clock
|
||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
code_assets:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: collection
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cupertino_icons
|
||||
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.8"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.5"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: glob
|
||||
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: "5d309c86e7ce34cd8e37aa71cb30cb652d3829b900ab145e4d9da564b31d59f7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
ini:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: ini
|
||||
sha256: "12a76c53591ffdf86d1265be3f986888a6dfeb34a85957774bc65912d989a173"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: intl
|
||||
sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.18.1"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
logging:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.17"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.1"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
native_toolchain_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_c
|
||||
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.17.4"
|
||||
nested:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: nested
|
||||
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
network_info_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: network_info_plus
|
||||
sha256: "4601b815b1c6a46d84839f65cd774a7d999738471d910fae00d813e9e98b04e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0+1"
|
||||
network_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: network_info_plus_platform_interface
|
||||
sha256: "881f5029c5edaf19c616c201d3d8b366c5b1384afd5c1da5a49e4345de82fb8b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.3"
|
||||
nm:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: nm
|
||||
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.0"
|
||||
path:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: plugin_platform_interface
|
||||
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
process_run:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: process_run
|
||||
sha256: "4539b7c0a34a4a4979e7345d9f5c6358961d9f53901baf2930807159d08ee59b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.5+3"
|
||||
provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: provider
|
||||
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.5+1"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.1"
|
||||
sqflite_common:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_common
|
||||
sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.6"
|
||||
sqflite_common_ffi:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqflite_common_ffi
|
||||
sha256: c59fcdc143839a77581f7a7c4de018e53682408903a0a0800b95ef2dc4033eff
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0+2"
|
||||
sqlite3:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlite3
|
||||
sha256: "00e5e65f8e9b556ed3d999ad310881c956ffb656ed96bea487a4c50ffdff6d14"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: synchronized
|
||||
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.0"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
uuid:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: uuid
|
||||
sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.2"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.0.2"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.15.0"
|
||||
xml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xml
|
||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.6.1"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: yaml
|
||||
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.10.0-0 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
54
pubspec.yaml
Normal file
54
pubspec.yaml
Normal file
@@ -0,0 +1,54 @@
|
||||
name: cloud_client_gui
|
||||
description: 云算力平台卖方客户端GUI
|
||||
publish_to: 'none'
|
||||
version: 1.0.0+1
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# UI相关
|
||||
cupertino_icons: ^1.0.6
|
||||
|
||||
# 状态管理
|
||||
provider: ^6.1.1
|
||||
|
||||
# 时间格式化
|
||||
intl: ^0.18.1
|
||||
|
||||
# 数据库
|
||||
# Windows 桌面端请使用 sqflite_common_ffi
|
||||
sqflite_common_ffi: ^2.3.3
|
||||
|
||||
# 路径工具(database.dart 使用 join)
|
||||
path: ^1.9.0
|
||||
|
||||
# 进程管理
|
||||
process_run: ^0.12.5+2
|
||||
|
||||
# 网络接口
|
||||
network_info_plus: ^4.0.2
|
||||
|
||||
# INI配置文件解析
|
||||
ini: ^2.1.0
|
||||
|
||||
# UUID生成
|
||||
uuid: ^4.1.0
|
||||
|
||||
# 日志
|
||||
logging: ^1.2.0
|
||||
|
||||
# HTTP 请求
|
||||
http: ^1.1.0
|
||||
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^3.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
24
test/widget_test.dart
Normal file
24
test/widget_test.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
// This is a basic Flutter widget test.
|
||||
//
|
||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||
// tree, read text, and verify that the values of widget properties are correct.
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:cloud_client_gui/main.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('App launches and shows main screen', (WidgetTester tester) async {
|
||||
// Build our app and trigger a frame.
|
||||
// Note: CloudClientApp already includes MultiProvider setup
|
||||
await tester.pumpWidget(const CloudClientApp());
|
||||
|
||||
// Wait for async initialization (give it time to initialize)
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
|
||||
// Verify that the app title is present in the AppBar
|
||||
expect(find.text('云算力平台客户端'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
17
windows/.gitignore
vendored
Normal file
17
windows/.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
flutter/ephemeral/
|
||||
|
||||
# Visual Studio user-specific files.
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# Visual Studio build-related files.
|
||||
x64/
|
||||
x86/
|
||||
|
||||
# Visual Studio cache files
|
||||
# files ending in .cache can be ignored
|
||||
*.[Cc]ache
|
||||
# but keep track of directories ending in .cache
|
||||
!*.[Cc]ache/
|
||||
108
windows/CMakeLists.txt
Normal file
108
windows/CMakeLists.txt
Normal file
@@ -0,0 +1,108 @@
|
||||
# Project-level configuration.
|
||||
cmake_minimum_required(VERSION 3.14)
|
||||
project(cloud_client_gui LANGUAGES CXX)
|
||||
|
||||
# The name of the executable created for the application. Change this to change
|
||||
# the on-disk name of your application.
|
||||
set(BINARY_NAME "cloud_client_gui")
|
||||
|
||||
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
||||
# versions of CMake.
|
||||
cmake_policy(VERSION 3.14...3.25)
|
||||
|
||||
# Define build configuration option.
|
||||
get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG)
|
||||
if(IS_MULTICONFIG)
|
||||
set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release"
|
||||
CACHE STRING "" FORCE)
|
||||
else()
|
||||
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
|
||||
set(CMAKE_BUILD_TYPE "Debug" CACHE
|
||||
STRING "Flutter build mode" FORCE)
|
||||
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
|
||||
"Debug" "Profile" "Release")
|
||||
endif()
|
||||
endif()
|
||||
# Define settings for the Profile build mode.
|
||||
set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}")
|
||||
set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}")
|
||||
set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}")
|
||||
set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}")
|
||||
|
||||
# Use Unicode for all projects.
|
||||
add_definitions(-DUNICODE -D_UNICODE)
|
||||
|
||||
# Compilation settings that should be applied to most targets.
|
||||
#
|
||||
# Be cautious about adding new options here, as plugins use this function by
|
||||
# default. In most cases, you should add new options to specific targets instead
|
||||
# of modifying this function.
|
||||
function(APPLY_STANDARD_SETTINGS TARGET)
|
||||
target_compile_features(${TARGET} PUBLIC cxx_std_17)
|
||||
target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100")
|
||||
target_compile_options(${TARGET} PRIVATE /EHsc)
|
||||
target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0")
|
||||
target_compile_definitions(${TARGET} PRIVATE "$<$<CONFIG:Debug>:_DEBUG>")
|
||||
endfunction()
|
||||
|
||||
# Flutter library and tool build rules.
|
||||
set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
|
||||
add_subdirectory(${FLUTTER_MANAGED_DIR})
|
||||
|
||||
# Application build; see runner/CMakeLists.txt.
|
||||
add_subdirectory("runner")
|
||||
|
||||
|
||||
# Generated plugin build rules, which manage building the plugins and adding
|
||||
# them to the application.
|
||||
include(flutter/generated_plugins.cmake)
|
||||
|
||||
|
||||
# === Installation ===
|
||||
# Support files are copied into place next to the executable, so that it can
|
||||
# run in place. This is done instead of making a separate bundle (as on Linux)
|
||||
# so that building and running from within Visual Studio will work.
|
||||
set(BUILD_BUNDLE_DIR "$<TARGET_FILE_DIR:${BINARY_NAME}>")
|
||||
# Make the "install" step default, as it's required to run.
|
||||
set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1)
|
||||
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
|
||||
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
|
||||
endif()
|
||||
|
||||
set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
|
||||
set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}")
|
||||
|
||||
install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
if(PLUGIN_BUNDLED_LIBRARIES)
|
||||
install(FILES "${PLUGIN_BUNDLED_LIBRARIES}"
|
||||
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
endif()
|
||||
|
||||
# Copy the native assets provided by the build.dart from all packages.
|
||||
set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/")
|
||||
install(DIRECTORY "${NATIVE_ASSETS_DIR}"
|
||||
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
# Fully re-copy the assets directory on each build to avoid having stale files
|
||||
# from a previous install.
|
||||
set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
|
||||
install(CODE "
|
||||
file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
|
||||
" COMPONENT Runtime)
|
||||
install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
|
||||
DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
|
||||
|
||||
# Install the AOT library on non-Debug builds only.
|
||||
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
|
||||
CONFIGURATIONS Profile;Release
|
||||
COMPONENT Runtime)
|
||||
109
windows/flutter/CMakeLists.txt
Normal file
109
windows/flutter/CMakeLists.txt
Normal file
@@ -0,0 +1,109 @@
|
||||
# This file controls Flutter-level build steps. It should not be edited.
|
||||
cmake_minimum_required(VERSION 3.14)
|
||||
|
||||
set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
|
||||
|
||||
# Configuration provided via flutter tool.
|
||||
include(${EPHEMERAL_DIR}/generated_config.cmake)
|
||||
|
||||
# TODO: Move the rest of this into files in ephemeral. See
|
||||
# https://github.com/flutter/flutter/issues/57146.
|
||||
set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper")
|
||||
|
||||
# Set fallback configurations for older versions of the flutter tool.
|
||||
if (NOT DEFINED FLUTTER_TARGET_PLATFORM)
|
||||
set(FLUTTER_TARGET_PLATFORM "windows-x64")
|
||||
endif()
|
||||
|
||||
# === Flutter Library ===
|
||||
set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll")
|
||||
|
||||
# Published to parent scope for install step.
|
||||
set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
|
||||
set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
|
||||
set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
|
||||
set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE)
|
||||
|
||||
list(APPEND FLUTTER_LIBRARY_HEADERS
|
||||
"flutter_export.h"
|
||||
"flutter_windows.h"
|
||||
"flutter_messenger.h"
|
||||
"flutter_plugin_registrar.h"
|
||||
"flutter_texture_registrar.h"
|
||||
)
|
||||
list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/")
|
||||
add_library(flutter INTERFACE)
|
||||
target_include_directories(flutter INTERFACE
|
||||
"${EPHEMERAL_DIR}"
|
||||
)
|
||||
target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib")
|
||||
add_dependencies(flutter flutter_assemble)
|
||||
|
||||
# === Wrapper ===
|
||||
list(APPEND CPP_WRAPPER_SOURCES_CORE
|
||||
"core_implementations.cc"
|
||||
"standard_codec.cc"
|
||||
)
|
||||
list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/")
|
||||
list(APPEND CPP_WRAPPER_SOURCES_PLUGIN
|
||||
"plugin_registrar.cc"
|
||||
)
|
||||
list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/")
|
||||
list(APPEND CPP_WRAPPER_SOURCES_APP
|
||||
"flutter_engine.cc"
|
||||
"flutter_view_controller.cc"
|
||||
)
|
||||
list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/")
|
||||
|
||||
# Wrapper sources needed for a plugin.
|
||||
add_library(flutter_wrapper_plugin STATIC
|
||||
${CPP_WRAPPER_SOURCES_CORE}
|
||||
${CPP_WRAPPER_SOURCES_PLUGIN}
|
||||
)
|
||||
apply_standard_settings(flutter_wrapper_plugin)
|
||||
set_target_properties(flutter_wrapper_plugin PROPERTIES
|
||||
POSITION_INDEPENDENT_CODE ON)
|
||||
set_target_properties(flutter_wrapper_plugin PROPERTIES
|
||||
CXX_VISIBILITY_PRESET hidden)
|
||||
target_link_libraries(flutter_wrapper_plugin PUBLIC flutter)
|
||||
target_include_directories(flutter_wrapper_plugin PUBLIC
|
||||
"${WRAPPER_ROOT}/include"
|
||||
)
|
||||
add_dependencies(flutter_wrapper_plugin flutter_assemble)
|
||||
|
||||
# Wrapper sources needed for the runner.
|
||||
add_library(flutter_wrapper_app STATIC
|
||||
${CPP_WRAPPER_SOURCES_CORE}
|
||||
${CPP_WRAPPER_SOURCES_APP}
|
||||
)
|
||||
apply_standard_settings(flutter_wrapper_app)
|
||||
target_link_libraries(flutter_wrapper_app PUBLIC flutter)
|
||||
target_include_directories(flutter_wrapper_app PUBLIC
|
||||
"${WRAPPER_ROOT}/include"
|
||||
)
|
||||
add_dependencies(flutter_wrapper_app flutter_assemble)
|
||||
|
||||
# === Flutter tool backend ===
|
||||
# _phony_ is a non-existent file to force this command to run every time,
|
||||
# since currently there's no way to get a full input/output list from the
|
||||
# flutter tool.
|
||||
set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_")
|
||||
set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE)
|
||||
add_custom_command(
|
||||
OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
|
||||
${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN}
|
||||
${CPP_WRAPPER_SOURCES_APP}
|
||||
${PHONY_OUTPUT}
|
||||
COMMAND ${CMAKE_COMMAND} -E env
|
||||
${FLUTTER_TOOL_ENVIRONMENT}
|
||||
"${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat"
|
||||
${FLUTTER_TARGET_PLATFORM} $<CONFIG>
|
||||
VERBATIM
|
||||
)
|
||||
add_custom_target(flutter_assemble DEPENDS
|
||||
"${FLUTTER_LIBRARY}"
|
||||
${FLUTTER_LIBRARY_HEADERS}
|
||||
${CPP_WRAPPER_SOURCES_CORE}
|
||||
${CPP_WRAPPER_SOURCES_PLUGIN}
|
||||
${CPP_WRAPPER_SOURCES_APP}
|
||||
)
|
||||
11
windows/flutter/generated_plugin_registrant.cc
Normal file
11
windows/flutter/generated_plugin_registrant.cc
Normal file
@@ -0,0 +1,11 @@
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
// clang-format off
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
}
|
||||
15
windows/flutter/generated_plugin_registrant.h
Normal file
15
windows/flutter/generated_plugin_registrant.h
Normal file
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
// clang-format off
|
||||
|
||||
#ifndef GENERATED_PLUGIN_REGISTRANT_
|
||||
#define GENERATED_PLUGIN_REGISTRANT_
|
||||
|
||||
#include <flutter/plugin_registry.h>
|
||||
|
||||
// Registers Flutter plugins.
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry);
|
||||
|
||||
#endif // GENERATED_PLUGIN_REGISTRANT_
|
||||
23
windows/flutter/generated_plugins.cmake
Normal file
23
windows/flutter/generated_plugins.cmake
Normal file
@@ -0,0 +1,23 @@
|
||||
#
|
||||
# Generated file, do not edit.
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
||||
foreach(plugin ${FLUTTER_PLUGIN_LIST})
|
||||
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin})
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
|
||||
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
|
||||
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
|
||||
endforeach(plugin)
|
||||
|
||||
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
|
||||
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin})
|
||||
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
|
||||
endforeach(ffi_plugin)
|
||||
40
windows/runner/CMakeLists.txt
Normal file
40
windows/runner/CMakeLists.txt
Normal file
@@ -0,0 +1,40 @@
|
||||
cmake_minimum_required(VERSION 3.14)
|
||||
project(runner LANGUAGES CXX)
|
||||
|
||||
# Define the application target. To change its name, change BINARY_NAME in the
|
||||
# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer
|
||||
# work.
|
||||
#
|
||||
# Any new source files that you add to the application should be added here.
|
||||
add_executable(${BINARY_NAME} WIN32
|
||||
"flutter_window.cpp"
|
||||
"main.cpp"
|
||||
"utils.cpp"
|
||||
"win32_window.cpp"
|
||||
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
|
||||
"Runner.rc"
|
||||
"runner.exe.manifest"
|
||||
)
|
||||
|
||||
# Apply the standard set of build settings. This can be removed for applications
|
||||
# that need different build settings.
|
||||
apply_standard_settings(${BINARY_NAME})
|
||||
|
||||
# Add preprocessor definitions for the build version.
|
||||
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"")
|
||||
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}")
|
||||
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}")
|
||||
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}")
|
||||
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}")
|
||||
|
||||
# Disable Windows macros that collide with C++ standard library functions.
|
||||
target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX")
|
||||
|
||||
# Add dependency libraries and include directories. Add any application-specific
|
||||
# dependencies here.
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app)
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib")
|
||||
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
|
||||
|
||||
# Run the Flutter tool portions of the build. This must not be removed.
|
||||
add_dependencies(${BINARY_NAME} flutter_assemble)
|
||||
121
windows/runner/Runner.rc
Normal file
121
windows/runner/Runner.rc
Normal file
@@ -0,0 +1,121 @@
|
||||
// Microsoft Visual C++ generated resource script.
|
||||
//
|
||||
#pragma code_page(65001)
|
||||
#include "resource.h"
|
||||
|
||||
#define APSTUDIO_READONLY_SYMBOLS
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// Generated from the TEXTINCLUDE 2 resource.
|
||||
//
|
||||
#include "winres.h"
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
#undef APSTUDIO_READONLY_SYMBOLS
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// English (United States) resources
|
||||
|
||||
#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU)
|
||||
LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US
|
||||
|
||||
#ifdef APSTUDIO_INVOKED
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// TEXTINCLUDE
|
||||
//
|
||||
|
||||
1 TEXTINCLUDE
|
||||
BEGIN
|
||||
"resource.h\0"
|
||||
END
|
||||
|
||||
2 TEXTINCLUDE
|
||||
BEGIN
|
||||
"#include ""winres.h""\r\n"
|
||||
"\0"
|
||||
END
|
||||
|
||||
3 TEXTINCLUDE
|
||||
BEGIN
|
||||
"\r\n"
|
||||
"\0"
|
||||
END
|
||||
|
||||
#endif // APSTUDIO_INVOKED
|
||||
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// Icon
|
||||
//
|
||||
|
||||
// Icon with lowest ID value placed first to ensure application icon
|
||||
// remains consistent on all systems.
|
||||
IDI_APP_ICON ICON "resources\\app_icon.ico"
|
||||
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// Version
|
||||
//
|
||||
|
||||
#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD)
|
||||
#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD
|
||||
#else
|
||||
#define VERSION_AS_NUMBER 1,0,0,0
|
||||
#endif
|
||||
|
||||
#if defined(FLUTTER_VERSION)
|
||||
#define VERSION_AS_STRING FLUTTER_VERSION
|
||||
#else
|
||||
#define VERSION_AS_STRING "1.0.0"
|
||||
#endif
|
||||
|
||||
VS_VERSION_INFO VERSIONINFO
|
||||
FILEVERSION VERSION_AS_NUMBER
|
||||
PRODUCTVERSION VERSION_AS_NUMBER
|
||||
FILEFLAGSMASK VS_FFI_FILEFLAGSMASK
|
||||
#ifdef _DEBUG
|
||||
FILEFLAGS VS_FF_DEBUG
|
||||
#else
|
||||
FILEFLAGS 0x0L
|
||||
#endif
|
||||
FILEOS VOS__WINDOWS32
|
||||
FILETYPE VFT_APP
|
||||
FILESUBTYPE 0x0L
|
||||
BEGIN
|
||||
BLOCK "StringFileInfo"
|
||||
BEGIN
|
||||
BLOCK "040904e4"
|
||||
BEGIN
|
||||
VALUE "CompanyName", "com.example" "\0"
|
||||
VALUE "FileDescription", "cloud_client_gui" "\0"
|
||||
VALUE "FileVersion", VERSION_AS_STRING "\0"
|
||||
VALUE "InternalName", "cloud_client_gui" "\0"
|
||||
VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0"
|
||||
VALUE "OriginalFilename", "cloud_client_gui.exe" "\0"
|
||||
VALUE "ProductName", "cloud_client_gui" "\0"
|
||||
VALUE "ProductVersion", VERSION_AS_STRING "\0"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
BEGIN
|
||||
VALUE "Translation", 0x409, 1252
|
||||
END
|
||||
END
|
||||
|
||||
#endif // English (United States) resources
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
|
||||
|
||||
#ifndef APSTUDIO_INVOKED
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// Generated from the TEXTINCLUDE 3 resource.
|
||||
//
|
||||
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
#endif // not APSTUDIO_INVOKED
|
||||
71
windows/runner/flutter_window.cpp
Normal file
71
windows/runner/flutter_window.cpp
Normal file
@@ -0,0 +1,71 @@
|
||||
#include "flutter_window.h"
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include "flutter/generated_plugin_registrant.h"
|
||||
|
||||
FlutterWindow::FlutterWindow(const flutter::DartProject& project)
|
||||
: project_(project) {}
|
||||
|
||||
FlutterWindow::~FlutterWindow() {}
|
||||
|
||||
bool FlutterWindow::OnCreate() {
|
||||
if (!Win32Window::OnCreate()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
RECT frame = GetClientArea();
|
||||
|
||||
// The size here must match the window dimensions to avoid unnecessary surface
|
||||
// creation / destruction in the startup path.
|
||||
flutter_controller_ = std::make_unique<flutter::FlutterViewController>(
|
||||
frame.right - frame.left, frame.bottom - frame.top, project_);
|
||||
// Ensure that basic setup of the controller was successful.
|
||||
if (!flutter_controller_->engine() || !flutter_controller_->view()) {
|
||||
return false;
|
||||
}
|
||||
RegisterPlugins(flutter_controller_->engine());
|
||||
SetChildContent(flutter_controller_->view()->GetNativeWindow());
|
||||
|
||||
flutter_controller_->engine()->SetNextFrameCallback([&]() {
|
||||
this->Show();
|
||||
});
|
||||
|
||||
// Flutter can complete the first frame before the "show window" callback is
|
||||
// registered. The following call ensures a frame is pending to ensure the
|
||||
// window is shown. It is a no-op if the first frame hasn't completed yet.
|
||||
flutter_controller_->ForceRedraw();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void FlutterWindow::OnDestroy() {
|
||||
if (flutter_controller_) {
|
||||
flutter_controller_ = nullptr;
|
||||
}
|
||||
|
||||
Win32Window::OnDestroy();
|
||||
}
|
||||
|
||||
LRESULT
|
||||
FlutterWindow::MessageHandler(HWND hwnd, UINT const message,
|
||||
WPARAM const wparam,
|
||||
LPARAM const lparam) noexcept {
|
||||
// Give Flutter, including plugins, an opportunity to handle window messages.
|
||||
if (flutter_controller_) {
|
||||
std::optional<LRESULT> result =
|
||||
flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam,
|
||||
lparam);
|
||||
if (result) {
|
||||
return *result;
|
||||
}
|
||||
}
|
||||
|
||||
switch (message) {
|
||||
case WM_FONTCHANGE:
|
||||
flutter_controller_->engine()->ReloadSystemFonts();
|
||||
break;
|
||||
}
|
||||
|
||||
return Win32Window::MessageHandler(hwnd, message, wparam, lparam);
|
||||
}
|
||||
33
windows/runner/flutter_window.h
Normal file
33
windows/runner/flutter_window.h
Normal file
@@ -0,0 +1,33 @@
|
||||
#ifndef RUNNER_FLUTTER_WINDOW_H_
|
||||
#define RUNNER_FLUTTER_WINDOW_H_
|
||||
|
||||
#include <flutter/dart_project.h>
|
||||
#include <flutter/flutter_view_controller.h>
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include "win32_window.h"
|
||||
|
||||
// A window that does nothing but host a Flutter view.
|
||||
class FlutterWindow : public Win32Window {
|
||||
public:
|
||||
// Creates a new FlutterWindow hosting a Flutter view running |project|.
|
||||
explicit FlutterWindow(const flutter::DartProject& project);
|
||||
virtual ~FlutterWindow();
|
||||
|
||||
protected:
|
||||
// Win32Window:
|
||||
bool OnCreate() override;
|
||||
void OnDestroy() override;
|
||||
LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam,
|
||||
LPARAM const lparam) noexcept override;
|
||||
|
||||
private:
|
||||
// The project to run.
|
||||
flutter::DartProject project_;
|
||||
|
||||
// The Flutter instance hosted by this window.
|
||||
std::unique_ptr<flutter::FlutterViewController> flutter_controller_;
|
||||
};
|
||||
|
||||
#endif // RUNNER_FLUTTER_WINDOW_H_
|
||||
43
windows/runner/main.cpp
Normal file
43
windows/runner/main.cpp
Normal file
@@ -0,0 +1,43 @@
|
||||
#include <flutter/dart_project.h>
|
||||
#include <flutter/flutter_view_controller.h>
|
||||
#include <windows.h>
|
||||
|
||||
#include "flutter_window.h"
|
||||
#include "utils.h"
|
||||
|
||||
int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
||||
_In_ wchar_t *command_line, _In_ int show_command) {
|
||||
// Attach to console when present (e.g., 'flutter run') or create a
|
||||
// new console when running with a debugger.
|
||||
if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) {
|
||||
CreateAndAttachConsole();
|
||||
}
|
||||
|
||||
// Initialize COM, so that it is available for use in the library and/or
|
||||
// plugins.
|
||||
::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||
|
||||
flutter::DartProject project(L"data");
|
||||
|
||||
std::vector<std::string> command_line_arguments =
|
||||
GetCommandLineArguments();
|
||||
|
||||
project.set_dart_entrypoint_arguments(std::move(command_line_arguments));
|
||||
|
||||
FlutterWindow window(project);
|
||||
Win32Window::Point origin(10, 10);
|
||||
Win32Window::Size size(1280, 720);
|
||||
if (!window.Create(L"cloud_client_gui", origin, size)) {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
window.SetQuitOnClose(true);
|
||||
|
||||
::MSG msg;
|
||||
while (::GetMessage(&msg, nullptr, 0, 0)) {
|
||||
::TranslateMessage(&msg);
|
||||
::DispatchMessage(&msg);
|
||||
}
|
||||
|
||||
::CoUninitialize();
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
16
windows/runner/resource.h
Normal file
16
windows/runner/resource.h
Normal file
@@ -0,0 +1,16 @@
|
||||
//{{NO_DEPENDENCIES}}
|
||||
// Microsoft Visual C++ generated include file.
|
||||
// Used by Runner.rc
|
||||
//
|
||||
#define IDI_APP_ICON 101
|
||||
|
||||
// Next default values for new objects
|
||||
//
|
||||
#ifdef APSTUDIO_INVOKED
|
||||
#ifndef APSTUDIO_READONLY_SYMBOLS
|
||||
#define _APS_NEXT_RESOURCE_VALUE 102
|
||||
#define _APS_NEXT_COMMAND_VALUE 40001
|
||||
#define _APS_NEXT_CONTROL_VALUE 1001
|
||||
#define _APS_NEXT_SYMED_VALUE 101
|
||||
#endif
|
||||
#endif
|
||||
BIN
windows/runner/resources/app_icon.ico
Normal file
BIN
windows/runner/resources/app_icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
14
windows/runner/runner.exe.manifest
Normal file
14
windows/runner/runner.exe.manifest
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- Windows 10 and Windows 11 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
65
windows/runner/utils.cpp
Normal file
65
windows/runner/utils.cpp
Normal file
@@ -0,0 +1,65 @@
|
||||
#include "utils.h"
|
||||
|
||||
#include <flutter_windows.h>
|
||||
#include <io.h>
|
||||
#include <stdio.h>
|
||||
#include <windows.h>
|
||||
|
||||
#include <iostream>
|
||||
|
||||
void CreateAndAttachConsole() {
|
||||
if (::AllocConsole()) {
|
||||
FILE *unused;
|
||||
if (freopen_s(&unused, "CONOUT$", "w", stdout)) {
|
||||
_dup2(_fileno(stdout), 1);
|
||||
}
|
||||
if (freopen_s(&unused, "CONOUT$", "w", stderr)) {
|
||||
_dup2(_fileno(stdout), 2);
|
||||
}
|
||||
std::ios::sync_with_stdio();
|
||||
FlutterDesktopResyncOutputStreams();
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<std::string> GetCommandLineArguments() {
|
||||
// Convert the UTF-16 command line arguments to UTF-8 for the Engine to use.
|
||||
int argc;
|
||||
wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc);
|
||||
if (argv == nullptr) {
|
||||
return std::vector<std::string>();
|
||||
}
|
||||
|
||||
std::vector<std::string> command_line_arguments;
|
||||
|
||||
// Skip the first argument as it's the binary name.
|
||||
for (int i = 1; i < argc; i++) {
|
||||
command_line_arguments.push_back(Utf8FromUtf16(argv[i]));
|
||||
}
|
||||
|
||||
::LocalFree(argv);
|
||||
|
||||
return command_line_arguments;
|
||||
}
|
||||
|
||||
std::string Utf8FromUtf16(const wchar_t* utf16_string) {
|
||||
if (utf16_string == nullptr) {
|
||||
return std::string();
|
||||
}
|
||||
unsigned int target_length = ::WideCharToMultiByte(
|
||||
CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string,
|
||||
-1, nullptr, 0, nullptr, nullptr)
|
||||
-1; // remove the trailing null character
|
||||
int input_length = (int)wcslen(utf16_string);
|
||||
std::string utf8_string;
|
||||
if (target_length == 0 || target_length > utf8_string.max_size()) {
|
||||
return utf8_string;
|
||||
}
|
||||
utf8_string.resize(target_length);
|
||||
int converted_length = ::WideCharToMultiByte(
|
||||
CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string,
|
||||
input_length, utf8_string.data(), target_length, nullptr, nullptr);
|
||||
if (converted_length == 0) {
|
||||
return std::string();
|
||||
}
|
||||
return utf8_string;
|
||||
}
|
||||
19
windows/runner/utils.h
Normal file
19
windows/runner/utils.h
Normal file
@@ -0,0 +1,19 @@
|
||||
#ifndef RUNNER_UTILS_H_
|
||||
#define RUNNER_UTILS_H_
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// Creates a console for the process, and redirects stdout and stderr to
|
||||
// it for both the runner and the Flutter library.
|
||||
void CreateAndAttachConsole();
|
||||
|
||||
// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string
|
||||
// encoded in UTF-8. Returns an empty std::string on failure.
|
||||
std::string Utf8FromUtf16(const wchar_t* utf16_string);
|
||||
|
||||
// Gets the command line arguments passed in as a std::vector<std::string>,
|
||||
// encoded in UTF-8. Returns an empty std::vector<std::string> on failure.
|
||||
std::vector<std::string> GetCommandLineArguments();
|
||||
|
||||
#endif // RUNNER_UTILS_H_
|
||||
288
windows/runner/win32_window.cpp
Normal file
288
windows/runner/win32_window.cpp
Normal file
@@ -0,0 +1,288 @@
|
||||
#include "win32_window.h"
|
||||
|
||||
#include <dwmapi.h>
|
||||
#include <flutter_windows.h>
|
||||
|
||||
#include "resource.h"
|
||||
|
||||
namespace {
|
||||
|
||||
/// Window attribute that enables dark mode window decorations.
|
||||
///
|
||||
/// Redefined in case the developer's machine has a Windows SDK older than
|
||||
/// version 10.0.22000.0.
|
||||
/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute
|
||||
#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE
|
||||
#define DWMWA_USE_IMMERSIVE_DARK_MODE 20
|
||||
#endif
|
||||
|
||||
constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW";
|
||||
|
||||
/// Registry key for app theme preference.
|
||||
///
|
||||
/// A value of 0 indicates apps should use dark mode. A non-zero or missing
|
||||
/// value indicates apps should use light mode.
|
||||
constexpr const wchar_t kGetPreferredBrightnessRegKey[] =
|
||||
L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize";
|
||||
constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme";
|
||||
|
||||
// The number of Win32Window objects that currently exist.
|
||||
static int g_active_window_count = 0;
|
||||
|
||||
using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd);
|
||||
|
||||
// Scale helper to convert logical scaler values to physical using passed in
|
||||
// scale factor
|
||||
int Scale(int source, double scale_factor) {
|
||||
return static_cast<int>(source * scale_factor);
|
||||
}
|
||||
|
||||
// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module.
|
||||
// This API is only needed for PerMonitor V1 awareness mode.
|
||||
void EnableFullDpiSupportIfAvailable(HWND hwnd) {
|
||||
HMODULE user32_module = LoadLibraryA("User32.dll");
|
||||
if (!user32_module) {
|
||||
return;
|
||||
}
|
||||
auto enable_non_client_dpi_scaling =
|
||||
reinterpret_cast<EnableNonClientDpiScaling*>(
|
||||
GetProcAddress(user32_module, "EnableNonClientDpiScaling"));
|
||||
if (enable_non_client_dpi_scaling != nullptr) {
|
||||
enable_non_client_dpi_scaling(hwnd);
|
||||
}
|
||||
FreeLibrary(user32_module);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// Manages the Win32Window's window class registration.
|
||||
class WindowClassRegistrar {
|
||||
public:
|
||||
~WindowClassRegistrar() = default;
|
||||
|
||||
// Returns the singleton registrar instance.
|
||||
static WindowClassRegistrar* GetInstance() {
|
||||
if (!instance_) {
|
||||
instance_ = new WindowClassRegistrar();
|
||||
}
|
||||
return instance_;
|
||||
}
|
||||
|
||||
// Returns the name of the window class, registering the class if it hasn't
|
||||
// previously been registered.
|
||||
const wchar_t* GetWindowClass();
|
||||
|
||||
// Unregisters the window class. Should only be called if there are no
|
||||
// instances of the window.
|
||||
void UnregisterWindowClass();
|
||||
|
||||
private:
|
||||
WindowClassRegistrar() = default;
|
||||
|
||||
static WindowClassRegistrar* instance_;
|
||||
|
||||
bool class_registered_ = false;
|
||||
};
|
||||
|
||||
WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr;
|
||||
|
||||
const wchar_t* WindowClassRegistrar::GetWindowClass() {
|
||||
if (!class_registered_) {
|
||||
WNDCLASS window_class{};
|
||||
window_class.hCursor = LoadCursor(nullptr, IDC_ARROW);
|
||||
window_class.lpszClassName = kWindowClassName;
|
||||
window_class.style = CS_HREDRAW | CS_VREDRAW;
|
||||
window_class.cbClsExtra = 0;
|
||||
window_class.cbWndExtra = 0;
|
||||
window_class.hInstance = GetModuleHandle(nullptr);
|
||||
window_class.hIcon =
|
||||
LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON));
|
||||
window_class.hbrBackground = 0;
|
||||
window_class.lpszMenuName = nullptr;
|
||||
window_class.lpfnWndProc = Win32Window::WndProc;
|
||||
RegisterClass(&window_class);
|
||||
class_registered_ = true;
|
||||
}
|
||||
return kWindowClassName;
|
||||
}
|
||||
|
||||
void WindowClassRegistrar::UnregisterWindowClass() {
|
||||
UnregisterClass(kWindowClassName, nullptr);
|
||||
class_registered_ = false;
|
||||
}
|
||||
|
||||
Win32Window::Win32Window() {
|
||||
++g_active_window_count;
|
||||
}
|
||||
|
||||
Win32Window::~Win32Window() {
|
||||
--g_active_window_count;
|
||||
Destroy();
|
||||
}
|
||||
|
||||
bool Win32Window::Create(const std::wstring& title,
|
||||
const Point& origin,
|
||||
const Size& size) {
|
||||
Destroy();
|
||||
|
||||
const wchar_t* window_class =
|
||||
WindowClassRegistrar::GetInstance()->GetWindowClass();
|
||||
|
||||
const POINT target_point = {static_cast<LONG>(origin.x),
|
||||
static_cast<LONG>(origin.y)};
|
||||
HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST);
|
||||
UINT dpi = FlutterDesktopGetDpiForMonitor(monitor);
|
||||
double scale_factor = dpi / 96.0;
|
||||
|
||||
HWND window = CreateWindow(
|
||||
window_class, title.c_str(), WS_OVERLAPPEDWINDOW,
|
||||
Scale(origin.x, scale_factor), Scale(origin.y, scale_factor),
|
||||
Scale(size.width, scale_factor), Scale(size.height, scale_factor),
|
||||
nullptr, nullptr, GetModuleHandle(nullptr), this);
|
||||
|
||||
if (!window) {
|
||||
return false;
|
||||
}
|
||||
|
||||
UpdateTheme(window);
|
||||
|
||||
return OnCreate();
|
||||
}
|
||||
|
||||
bool Win32Window::Show() {
|
||||
return ShowWindow(window_handle_, SW_SHOWNORMAL);
|
||||
}
|
||||
|
||||
// static
|
||||
LRESULT CALLBACK Win32Window::WndProc(HWND const window,
|
||||
UINT const message,
|
||||
WPARAM const wparam,
|
||||
LPARAM const lparam) noexcept {
|
||||
if (message == WM_NCCREATE) {
|
||||
auto window_struct = reinterpret_cast<CREATESTRUCT*>(lparam);
|
||||
SetWindowLongPtr(window, GWLP_USERDATA,
|
||||
reinterpret_cast<LONG_PTR>(window_struct->lpCreateParams));
|
||||
|
||||
auto that = static_cast<Win32Window*>(window_struct->lpCreateParams);
|
||||
EnableFullDpiSupportIfAvailable(window);
|
||||
that->window_handle_ = window;
|
||||
} else if (Win32Window* that = GetThisFromHandle(window)) {
|
||||
return that->MessageHandler(window, message, wparam, lparam);
|
||||
}
|
||||
|
||||
return DefWindowProc(window, message, wparam, lparam);
|
||||
}
|
||||
|
||||
LRESULT
|
||||
Win32Window::MessageHandler(HWND hwnd,
|
||||
UINT const message,
|
||||
WPARAM const wparam,
|
||||
LPARAM const lparam) noexcept {
|
||||
switch (message) {
|
||||
case WM_DESTROY:
|
||||
window_handle_ = nullptr;
|
||||
Destroy();
|
||||
if (quit_on_close_) {
|
||||
PostQuitMessage(0);
|
||||
}
|
||||
return 0;
|
||||
|
||||
case WM_DPICHANGED: {
|
||||
auto newRectSize = reinterpret_cast<RECT*>(lparam);
|
||||
LONG newWidth = newRectSize->right - newRectSize->left;
|
||||
LONG newHeight = newRectSize->bottom - newRectSize->top;
|
||||
|
||||
SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth,
|
||||
newHeight, SWP_NOZORDER | SWP_NOACTIVATE);
|
||||
|
||||
return 0;
|
||||
}
|
||||
case WM_SIZE: {
|
||||
RECT rect = GetClientArea();
|
||||
if (child_content_ != nullptr) {
|
||||
// Size and position the child window.
|
||||
MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left,
|
||||
rect.bottom - rect.top, TRUE);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
case WM_ACTIVATE:
|
||||
if (child_content_ != nullptr) {
|
||||
SetFocus(child_content_);
|
||||
}
|
||||
return 0;
|
||||
|
||||
case WM_DWMCOLORIZATIONCOLORCHANGED:
|
||||
UpdateTheme(hwnd);
|
||||
return 0;
|
||||
}
|
||||
|
||||
return DefWindowProc(window_handle_, message, wparam, lparam);
|
||||
}
|
||||
|
||||
void Win32Window::Destroy() {
|
||||
OnDestroy();
|
||||
|
||||
if (window_handle_) {
|
||||
DestroyWindow(window_handle_);
|
||||
window_handle_ = nullptr;
|
||||
}
|
||||
if (g_active_window_count == 0) {
|
||||
WindowClassRegistrar::GetInstance()->UnregisterWindowClass();
|
||||
}
|
||||
}
|
||||
|
||||
Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept {
|
||||
return reinterpret_cast<Win32Window*>(
|
||||
GetWindowLongPtr(window, GWLP_USERDATA));
|
||||
}
|
||||
|
||||
void Win32Window::SetChildContent(HWND content) {
|
||||
child_content_ = content;
|
||||
SetParent(content, window_handle_);
|
||||
RECT frame = GetClientArea();
|
||||
|
||||
MoveWindow(content, frame.left, frame.top, frame.right - frame.left,
|
||||
frame.bottom - frame.top, true);
|
||||
|
||||
SetFocus(child_content_);
|
||||
}
|
||||
|
||||
RECT Win32Window::GetClientArea() {
|
||||
RECT frame;
|
||||
GetClientRect(window_handle_, &frame);
|
||||
return frame;
|
||||
}
|
||||
|
||||
HWND Win32Window::GetHandle() {
|
||||
return window_handle_;
|
||||
}
|
||||
|
||||
void Win32Window::SetQuitOnClose(bool quit_on_close) {
|
||||
quit_on_close_ = quit_on_close;
|
||||
}
|
||||
|
||||
bool Win32Window::OnCreate() {
|
||||
// No-op; provided for subclasses.
|
||||
return true;
|
||||
}
|
||||
|
||||
void Win32Window::OnDestroy() {
|
||||
// No-op; provided for subclasses.
|
||||
}
|
||||
|
||||
void Win32Window::UpdateTheme(HWND const window) {
|
||||
DWORD light_mode;
|
||||
DWORD light_mode_size = sizeof(light_mode);
|
||||
LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey,
|
||||
kGetPreferredBrightnessRegValue,
|
||||
RRF_RT_REG_DWORD, nullptr, &light_mode,
|
||||
&light_mode_size);
|
||||
|
||||
if (result == ERROR_SUCCESS) {
|
||||
BOOL enable_dark_mode = light_mode == 0;
|
||||
DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE,
|
||||
&enable_dark_mode, sizeof(enable_dark_mode));
|
||||
}
|
||||
}
|
||||
102
windows/runner/win32_window.h
Normal file
102
windows/runner/win32_window.h
Normal file
@@ -0,0 +1,102 @@
|
||||
#ifndef RUNNER_WIN32_WINDOW_H_
|
||||
#define RUNNER_WIN32_WINDOW_H_
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
// A class abstraction for a high DPI-aware Win32 Window. Intended to be
|
||||
// inherited from by classes that wish to specialize with custom
|
||||
// rendering and input handling
|
||||
class Win32Window {
|
||||
public:
|
||||
struct Point {
|
||||
unsigned int x;
|
||||
unsigned int y;
|
||||
Point(unsigned int x, unsigned int y) : x(x), y(y) {}
|
||||
};
|
||||
|
||||
struct Size {
|
||||
unsigned int width;
|
||||
unsigned int height;
|
||||
Size(unsigned int width, unsigned int height)
|
||||
: width(width), height(height) {}
|
||||
};
|
||||
|
||||
Win32Window();
|
||||
virtual ~Win32Window();
|
||||
|
||||
// Creates a win32 window with |title| that is positioned and sized using
|
||||
// |origin| and |size|. New windows are created on the default monitor. Window
|
||||
// sizes are specified to the OS in physical pixels, hence to ensure a
|
||||
// consistent size this function will scale the inputted width and height as
|
||||
// as appropriate for the default monitor. The window is invisible until
|
||||
// |Show| is called. Returns true if the window was created successfully.
|
||||
bool Create(const std::wstring& title, const Point& origin, const Size& size);
|
||||
|
||||
// Show the current window. Returns true if the window was successfully shown.
|
||||
bool Show();
|
||||
|
||||
// Release OS resources associated with window.
|
||||
void Destroy();
|
||||
|
||||
// Inserts |content| into the window tree.
|
||||
void SetChildContent(HWND content);
|
||||
|
||||
// Returns the backing Window handle to enable clients to set icon and other
|
||||
// window properties. Returns nullptr if the window has been destroyed.
|
||||
HWND GetHandle();
|
||||
|
||||
// If true, closing this window will quit the application.
|
||||
void SetQuitOnClose(bool quit_on_close);
|
||||
|
||||
// Return a RECT representing the bounds of the current client area.
|
||||
RECT GetClientArea();
|
||||
|
||||
protected:
|
||||
// Processes and route salient window messages for mouse handling,
|
||||
// size change and DPI. Delegates handling of these to member overloads that
|
||||
// inheriting classes can handle.
|
||||
virtual LRESULT MessageHandler(HWND window,
|
||||
UINT const message,
|
||||
WPARAM const wparam,
|
||||
LPARAM const lparam) noexcept;
|
||||
|
||||
// Called when CreateAndShow is called, allowing subclass window-related
|
||||
// setup. Subclasses should return false if setup fails.
|
||||
virtual bool OnCreate();
|
||||
|
||||
// Called when Destroy is called.
|
||||
virtual void OnDestroy();
|
||||
|
||||
private:
|
||||
friend class WindowClassRegistrar;
|
||||
|
||||
// OS callback called by message pump. Handles the WM_NCCREATE message which
|
||||
// is passed when the non-client area is being created and enables automatic
|
||||
// non-client DPI scaling so that the non-client area automatically
|
||||
// responds to changes in DPI. All other messages are handled by
|
||||
// MessageHandler.
|
||||
static LRESULT CALLBACK WndProc(HWND const window,
|
||||
UINT const message,
|
||||
WPARAM const wparam,
|
||||
LPARAM const lparam) noexcept;
|
||||
|
||||
// Retrieves a class instance pointer for |window|
|
||||
static Win32Window* GetThisFromHandle(HWND const window) noexcept;
|
||||
|
||||
// Update the window frame's theme to match the system theme.
|
||||
static void UpdateTheme(HWND const window);
|
||||
|
||||
bool quit_on_close_ = false;
|
||||
|
||||
// window handle for top level window.
|
||||
HWND window_handle_ = nullptr;
|
||||
|
||||
// window handle for hosted content.
|
||||
HWND child_content_ = nullptr;
|
||||
};
|
||||
|
||||
#endif // RUNNER_WIN32_WINDOW_H_
|
||||
Reference in New Issue
Block a user