From 1fe0e54138b8bc7f0f7089e60bc9ba936236ef62 Mon Sep 17 00:00:00 2001 From: lzx <393768033@qq.com> Date: Thu, 22 Jan 2026 15:14:27 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BA=91=E7=AE=97=E5=8A=9B=E5=B9=B3=E5=8F=B0wi?= =?UTF-8?q?ndows=E6=A1=8C=E9=9D=A2=E5=BA=94=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 45 ++ .metadata | 30 ++ README.md | 189 +++++++ analysis_options.yaml | 6 + bin/auth | 1 + bin/auth copy | 1 + bin/mining.linux.conf | 26 + bin/mining.windows.conf | 30 ++ bin/mining_task copy.db | Bin 0 -> 12288 bytes bin/mining_task.db | Bin 0 -> 12288 bytes bin/user-manual.txt | 22 + bin/version | 1 + bin/用户手册.txt | 23 + lib/core/client_core.dart | 349 ++++++++++++ lib/core/database.dart | 185 +++++++ lib/core/mining_manager.dart | 214 ++++++++ lib/core/mining_task_info.dart | 40 ++ lib/core/sustain_miner.dart | 232 ++++++++ lib/core/system_info.dart | 198 +++++++ lib/main.dart | 41 ++ lib/models/client_status.dart | 96 ++++ lib/providers/client_provider.dart | 408 ++++++++++++++ lib/screens/config_editor_screen.dart | 392 ++++++++++++++ lib/screens/log_viewer_screen.dart | 145 +++++ lib/screens/main_screen.dart | 426 +++++++++++++++ lib/screens/mining_info_screen.dart | 239 +++++++++ lib/services/config_service.dart | 67 +++ lib/services/log_service.dart | 91 ++++ lib/services/update_service.dart | 154 ++++++ lib/utils/ini_utils.dart | 8 + lib/utils/path_utils.dart | 117 ++++ pubspec.lock | 498 ++++++++++++++++++ pubspec.yaml | 54 ++ test/widget_test.dart | 24 + windows/.gitignore | 17 + windows/CMakeLists.txt | 108 ++++ windows/flutter/CMakeLists.txt | 109 ++++ .../flutter/generated_plugin_registrant.cc | 11 + windows/flutter/generated_plugin_registrant.h | 15 + windows/flutter/generated_plugins.cmake | 23 + windows/runner/CMakeLists.txt | 40 ++ windows/runner/Runner.rc | 121 +++++ windows/runner/flutter_window.cpp | 71 +++ windows/runner/flutter_window.h | 33 ++ windows/runner/main.cpp | 43 ++ windows/runner/resource.h | 16 + windows/runner/resources/app_icon.ico | Bin 0 -> 33772 bytes windows/runner/runner.exe.manifest | 14 + windows/runner/utils.cpp | 65 +++ windows/runner/utils.h | 19 + windows/runner/win32_window.cpp | 288 ++++++++++ windows/runner/win32_window.h | 102 ++++ 52 files changed, 5447 insertions(+) create mode 100644 .gitignore create mode 100644 .metadata create mode 100644 README.md create mode 100644 analysis_options.yaml create mode 100644 bin/auth create mode 100644 bin/auth copy create mode 100644 bin/mining.linux.conf create mode 100644 bin/mining.windows.conf create mode 100644 bin/mining_task copy.db create mode 100644 bin/mining_task.db create mode 100644 bin/user-manual.txt create mode 100644 bin/version create mode 100644 bin/用户手册.txt create mode 100644 lib/core/client_core.dart create mode 100644 lib/core/database.dart create mode 100644 lib/core/mining_manager.dart create mode 100644 lib/core/mining_task_info.dart create mode 100644 lib/core/sustain_miner.dart create mode 100644 lib/core/system_info.dart create mode 100644 lib/main.dart create mode 100644 lib/models/client_status.dart create mode 100644 lib/providers/client_provider.dart create mode 100644 lib/screens/config_editor_screen.dart create mode 100644 lib/screens/log_viewer_screen.dart create mode 100644 lib/screens/main_screen.dart create mode 100644 lib/screens/mining_info_screen.dart create mode 100644 lib/services/config_service.dart create mode 100644 lib/services/log_service.dart create mode 100644 lib/services/update_service.dart create mode 100644 lib/utils/ini_utils.dart create mode 100644 lib/utils/path_utils.dart create mode 100644 pubspec.lock create mode 100644 pubspec.yaml create mode 100644 test/widget_test.dart create mode 100644 windows/.gitignore create mode 100644 windows/CMakeLists.txt create mode 100644 windows/flutter/CMakeLists.txt create mode 100644 windows/flutter/generated_plugin_registrant.cc create mode 100644 windows/flutter/generated_plugin_registrant.h create mode 100644 windows/flutter/generated_plugins.cmake create mode 100644 windows/runner/CMakeLists.txt create mode 100644 windows/runner/Runner.rc create mode 100644 windows/runner/flutter_window.cpp create mode 100644 windows/runner/flutter_window.h create mode 100644 windows/runner/main.cpp create mode 100644 windows/runner/resource.h create mode 100644 windows/runner/resources/app_icon.ico create mode 100644 windows/runner/runner.exe.manifest create mode 100644 windows/runner/utils.cpp create mode 100644 windows/runner/utils.h create mode 100644 windows/runner/win32_window.cpp create mode 100644 windows/runner/win32_window.h diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..3d2d7d1 --- /dev/null +++ b/.metadata @@ -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' diff --git a/README.md b/README.md new file mode 100644 index 0000000..4875543 --- /dev/null +++ b/README.md @@ -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 通信和心跳机制 +- 挖矿软件管理 +- 持续挖矿支持 +- 系统信息获取 +- 配置管理 +- 版本更新 diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..b2e2a92 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,6 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + prefer_const_constructors: true + prefer_const_literals_to_create_immutables: true diff --git a/bin/auth b/bin/auth new file mode 100644 index 0000000..4661b7c --- /dev/null +++ b/bin/auth @@ -0,0 +1 @@ +393768033@qq.com \ No newline at end of file diff --git a/bin/auth copy b/bin/auth copy new file mode 100644 index 0000000..4661b7c --- /dev/null +++ b/bin/auth copy @@ -0,0 +1 @@ +393768033@qq.com \ No newline at end of file diff --git a/bin/mining.linux.conf b/bin/mining.linux.conf new file mode 100644 index 0000000..5acae20 --- /dev/null +++ b/bin/mining.linux.conf @@ -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打开时同时打开本配置 \ No newline at end of file diff --git a/bin/mining.windows.conf b/bin/mining.windows.conf new file mode 100644 index 0000000..1300f03 --- /dev/null +++ b/bin/mining.windows.conf @@ -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打开时同时打开本配置 \ No newline at end of file diff --git a/bin/mining_task copy.db b/bin/mining_task copy.db new file mode 100644 index 0000000000000000000000000000000000000000..9784f3b7c30b2e939f4e0ce32e5fc81989798ac5 GIT binary patch literal 12288 zcmeI#&riZI6bJAY{E?AJJV?0q2!VLv><{#qU=+Tz2 zCC(V{t@%lsU*>{<00bZa0SG_<0uX=z1Rwwb2>dB9shzZYJv!CmHI`}>n~^n8ah-vr2C#6^ZPdo!#v*J#DvI^gY=Ci&k^W^&=Z9`vQlTS|X`Dx%QLEqvepkHz91j~0SG_<0uX=z1Rwwb2>c^~aqYC(?HW_XUjrdmfo`Cf6suH3 zv;4Gv?^vd5QP;d1STvX0j(8|~`peGy+Bj>rTE^$qre^3zel%x_%Z1#3t{qg{e?!~n z^dNkX_HEaCupBye`a{#1(no7b&51ke+q%rqvfW _instance; + ClientCore._internal(); + + final Logger _logger = Logger('ClientCore'); + + Socket? _socket; + String? _serverUrl; + String? _auth; + String? _machineCode; + bool _isConnected = false; + DateTime? _lastPingTime; + Timer? _heartbeatTimer; + StreamController? _logController; + + // 需要GPU和挖矿软件信息用于认证 + Map? _gpusInfo; + List? _miningSofts; + + // 状态回调 + Function(ui.ClientStatus)? onStatusChanged; + Function(MiningTaskInfo?)? onMiningTaskChanged; + + Stream get logStream => _logController?.stream ?? const Stream.empty(); + bool get isConnected => _isConnected; + + /// 更新系统信息(用于后台异步加载后更新) + void setSystemInfo(Map gpusInfo, List miningSofts) { + _gpusInfo = gpusInfo; + _miningSofts = miningSofts; + // 如果已连接,重新发送认证消息更新服务器 + if (_isConnected) { + _sendMachineCode(); + } + } + + /// 初始化客户端 + Future initialize({ + required String serverUrl, + required String auth, + required String machineCode, + Map? gpusInfo, + List? miningSofts, + }) async { + _serverUrl = serverUrl; + _auth = auth; + _machineCode = machineCode; + _gpusInfo = gpusInfo; + _miningSofts = miningSofts; + _logController = StreamController.broadcast(); + + try { + await _connect(); + // 注意:不在这里发送身份认证,等待机器码获取完成后再发送 + _startHeartbeat(); + return true; + } catch (e) { + _logger.severe('初始化失败: $e'); + return false; + } + } + + /// 连接到服务器 + Future _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 gpusInfo, List 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 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 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; + 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 msg) { + _lastPingTime = DateTime.now(); + + // 回复 pong + final pongMsg = { + 'id': msg['id'], + 'method': 'pong', + 'params': null, + }; + _sendMessage(pongMsg); + } + + /// 处理挖矿请求 + void _handleMiningRequest(Map msg) { + try { + final params = msg['params'] as Map?; + 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 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); + } +} diff --git a/lib/core/database.dart b/lib/core/database.dart new file mode 100644 index 0000000..4c0a386 --- /dev/null +++ b/lib/core/database.dart @@ -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 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 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 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 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>> 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 close() async { + await _database?.close(); + _database = null; + } +} diff --git a/lib/core/mining_manager.dart b/lib/core/mining_manager.dart new file mode 100644 index 0000000..889ec1b --- /dev/null +++ b/lib/core/mining_manager.dart @@ -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 _minerLogController = StreamController.broadcast(); + + /// 启动挖矿 + Future 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 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 _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 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, + }); +} diff --git a/lib/core/mining_task_info.dart b/lib/core/mining_task_info.dart new file mode 100644 index 0000000..36398ba --- /dev/null +++ b/lib/core/mining_task_info.dart @@ -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 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', // 默认值 + ); + } +} diff --git a/lib/core/sustain_miner.dart b/lib/core/sustain_miner.dart new file mode 100644 index 0000000..ceb5326 --- /dev/null +++ b/lib/core/sustain_miner.dart @@ -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 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 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 stop() async { + _isRunning = false; + _monitorTimer?.cancel(); + _monitorTimer = null; + + if (_miningManager.isMining && _miningManager.currentTask == null) { + // 只有持续挖矿任务在运行时才停止 + await _miningManager.stopMining(); + } + + _logger.info('持续挖矿已停止'); + } + + /// 暂停持续挖矿 + Future pause() async { + if (!_isRunning || _isPaused) { + return; + } + + _logger.info('暂停持续挖矿(有新任务)...'); + _isPaused = true; + + if (_miningManager.isMining && _miningManager.currentTask == null) { + await _miningManager.stopMining(); + } + } + + /// 恢复持续挖矿 + Future resume() async { + if (!_isRunning || !_isPaused) { + return; + } + + if (_miningManager.isMining) { + _logger.info('当前有挖矿任务,等待任务结束后恢复持续挖矿'); + return; + } + + _logger.info('恢复持续挖矿...'); + _isPaused = false; + await _startMining(); + } + + /// 启动挖矿 + Future _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, + }); +} diff --git a/lib/core/system_info.dart b/lib/core/system_info.dart new file mode 100644 index 0000000..2aea993 --- /dev/null +++ b/lib/core/system_info.dart @@ -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 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> 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 = []; + + 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 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 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 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; + } + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..2ec6549 --- /dev/null +++ b/lib/main.dart @@ -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, + ), + ); + } +} diff --git a/lib/models/client_status.dart b/lib/models/client_status.dart new file mode 100644 index 0000000..e7a2aa7 --- /dev/null +++ b/lib/models/client_status.dart @@ -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 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 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, + }); +} diff --git a/lib/providers/client_provider.dart b/lib/providers/client_provider.dart new file mode 100644 index 0000000..2bd221e --- /dev/null +++ b/lib/providers/client_provider.dart @@ -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 _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 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 = {}; + 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 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 restart() async { + _clientCore.stop(); + await _miningManager.stopMining(); + await _sustainMiner.stop(); + + _isInitialized = false; + await _initialize(); + } + + /// 获取支持的挖矿软件列表(辅助方法) + List _getMiningSofts(MiningConfig? miningConfig) { + final miningSofts = []; + 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 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 _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 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(); + } +} diff --git a/lib/screens/config_editor_screen.dart b/lib/screens/config_editor_screen.dart new file mode 100644 index 0000000..7cab3bc --- /dev/null +++ b/lib/screens/config_editor_screen.dart @@ -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 createState() => _ConfigEditorScreenState(); +} + +class _ConfigEditorScreenState extends State { + final ConfigService _configService = ConfigService(); + bool _isLoading = false; + bool _isModified = false; + + // 配置项数据 + final Map> _controllers = {}; + final Map> _enabledFlags = {}; + final List _sections = []; + + @override + void initState() { + super.initState(); + _loadConfig(); + } + + Future _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 _saveConfig() async { + final confirmed = await showDialog( + 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, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/log_viewer_screen.dart b/lib/screens/log_viewer_screen.dart new file mode 100644 index 0000000..af6949d --- /dev/null +++ b/lib/screens/log_viewer_screen.dart @@ -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 createState() => _LogViewerScreenState(); +} + +class _LogViewerScreenState extends State { + final LogService _logService = LogService(); + final ScrollController _scrollController = ScrollController(); + String _logs = ''; + StreamSubscription? _logSubscription; + StreamSubscription? _clientLogSubscription; + bool _autoScroll = true; + + @override + void initState() { + super.initState(); + _loadLogs(); + _startMonitoring(); + } + + Future _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(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( + 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, + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart new file mode 100644 index 0000000..369ebf0 --- /dev/null +++ b/lib/screens/main_screen.dart @@ -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( + 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 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 _performUpdate(BuildContext context, ClientProvider provider) async { + final confirmed = await showDialog( + 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( + 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(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( + 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); + } + } +} diff --git a/lib/screens/mining_info_screen.dart b/lib/screens/mining_info_screen.dart new file mode 100644 index 0000000..bcc1ede --- /dev/null +++ b/lib/screens/mining_info_screen.dart @@ -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 createState() => _MiningInfoScreenState(); +} + +class _MiningInfoScreenState extends State { + final MiningManager _miningManager = MiningManager(); + final ScrollController _logScrollController = ScrollController(); + final List _minerLogs = []; + StreamSubscription? _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, + ), + ), + ), + ); + } +} diff --git a/lib/services/config_service.dart b/lib/services/config_service.dart new file mode 100644 index 0000000..8dacbed --- /dev/null +++ b/lib/services/config_service.dart @@ -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 readConfig() async { + try { + final file = File(_configFile); + if (await file.exists()) { + return await file.readAsString(); + } + } catch (e) { + // 静默失败,返回空字符串 + } + return ''; + } + + /// 保存配置文件 + Future saveConfig(String content) async { + try { + final file = File(_configFile); + await file.writeAsString(content); + return true; + } catch (e) { + return false; + } + } + + /// 解析配置文件并返回 MiningConfig + Future 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; + } + } +} diff --git a/lib/services/log_service.dart b/lib/services/log_service.dart new file mode 100644 index 0000000..3cdb9a5 --- /dev/null +++ b/lib/services/log_service.dart @@ -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 _logController = StreamController.broadcast(); + IOSink? _logFile; + + Stream get logStream => _logController.stream; + + /// 初始化日志系统 + Future 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 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 clearLogs() async { + try { + final file = File(PathUtils.binFile('logs/client.log')); + if (await file.exists()) { + await file.writeAsString(''); + _logController.add('日志已清理'); + } + } catch (e) { + // 静默失败 + } + } + + /// 关闭日志服务 + Future close() async { + await _logFile?.flush(); + await _logFile?.close(); + await _logController.close(); + } +} diff --git a/lib/services/update_service.dart b/lib/services/update_service.dart new file mode 100644 index 0000000..5230690 --- /dev/null +++ b/lib/services/update_service.dart @@ -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 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 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 _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, + }); +} diff --git a/lib/utils/ini_utils.dart b/lib/utils/ini_utils.dart new file mode 100644 index 0000000..a98378a --- /dev/null +++ b/lib/utils/ini_utils.dart @@ -0,0 +1,8 @@ +import 'package:ini/ini.dart'; + +/// 解析 INI 字符串为 Config +Config parseIni(String content) { + // 对于 ini 2.x,fromString 接受完整字符串 + return Config.fromString(content); +} + diff --git a/lib/utils/path_utils.dart b/lib/utils/path_utils.dart new file mode 100644 index 0000000..3bf333e --- /dev/null +++ b/lib/utils/path_utils.dart @@ -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); + } +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..bdef673 --- /dev/null +++ b/pubspec.lock @@ -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" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..5c4f9fe --- /dev/null +++ b/pubspec.yaml @@ -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 diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..36e5d65 --- /dev/null +++ b/test/widget_test.dart @@ -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); + }); +} diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/windows/.gitignore @@ -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/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 0000000..4173505 --- /dev/null +++ b/windows/CMakeLists.txt @@ -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 "$<$:_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 "$") +# 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) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -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} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..8b6d468 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void RegisterPlugins(flutter::PluginRegistry* registry) { +} diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..b93c4c3 --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -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 $) + 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) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -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) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 0000000..430708d --- /dev/null +++ b/windows/runner/Runner.rc @@ -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 diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#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( + 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 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); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#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_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 0000000..f4f729f --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#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 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; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/windows/runner/resource.h @@ -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 diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..c04e20caf6370ebb9253ad831cc31de4a9c965f6 GIT binary patch literal 33772 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 literal 0 HcmV?d00001 diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 0000000..3a0b465 --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +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 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::vector 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; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// 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, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#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(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( + 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(origin.x), + static_cast(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(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(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(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( + 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)); + } +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// 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_