feat(windows): 向导内置 HiTi Windows 驱动一键安装(对齐 mac 的"装 App 即用"体验)

用户实测定论:CS-2xx 的 USB 是自定义设备(驱动包内含 CS2xxhcd.inf 内核驱动),
PavoApi 直打依赖驱动建立的 USB 通道——无驱动时枚举到设备但状态返回离线,
"免驱动"假设不成立。产品要求两平台一致:驱动随 App 内置、向导一键安装。

- assets/drivers/CS2xx_Windows_Driver.zip:HiTi 官网 CS-2xx Windows 驱动
  (2.6.25.48)整目录 zip(含 CS2xx/Setup64.exe),33MB,不入 git
- DriverInstall Windows 分支:Expand-Archive 解压到临时目录 → 递归定位
  Setup64.exe(兜底 setup/autorun)→ PowerShell Start-Process -Verb RunAs -Wait
  提权安装(UAC 一次)→ 写安装标记(HKCU\Software\zax\desktop-plugin,
  系统驱动安装状态无统一可查标志)→ finally 清理临时目录
- 向导第 2 步统一双平台状态机:驱动未装(异步查标记/目录)→"一键安装驱动
  (内置安装包)";轮询 3 秒自动刷新安装/连接状态
- bundledInstallerPath 双平台资产路径推导(mac .dmg / win .zip);
  .gitignore 增 *.zip;assets/drivers/README 更新双平台放置约定
This commit is contained in:
dzc committed 2026-09-21 16:38:11 +08:00
1 parent 527baf7ec5
commit 170e9ddc5e
4 files changed
+156 -53

No files matched your search

+2
View File
@@ -45,3 +45,5 @@ app.*.map.json
/android/app/release
/dist/
assets/drivers/*.dmg
assets/drivers/*.exe
assets/drivers/*.zip
+8 -7
View File
@@ -1,9 +1,10 @@
# HiTi Mac 驱动安装包目录
# HiTi 驱动安装包目录
此目录用于打包时**内置 HiTi macOS 打印驱动**(向导"一键安装驱动"功能的数据源):
此目录用于打包时**内置 HiTi 打印驱动**(向导"一键安装驱动"功能的数据源):
- 构建 macOS 安装包前,把 `HiTiCardTypeMacDrivers_2.4.2.dmg`(约 25MB)放入本目录,
然后执行 `bash tool/package_macos.sh`——驱动会随 dmg 安装包分发
- **dmg 文件本身不入 git**(.gitignore 已忽略 `assets/drivers/*.dmg`)
- 缺少 dmg 时构建不受影响;向导会自动降级为"官网链接手动下载"模式
- **Windows 不需要此目录放任何东西**(PavoApi 直打,驱动可选)
- **macOS**:放 `HiTiCardTypeMacDrivers_2.4.2.dmg`(约 25MB)
- **Windows**:放 `CS2xx_Windows_Driver.zip`(HiTi 官网 CS-2xx Windows 驱动整个文件夹打成 zip,
内含 `CS2xx/Setup64.exe`;约 33MB)。mac 上打 zip 注意排除 `__MACOSX`:
`cd <驱动所在目录> && zip -r CS2xx_Windows_Driver.zip CS2_Driver_x.x.x.x -x "*.DS_Store"`
- 安装包文件**不入 git**(.gitignore 已忽略 `*.dmg/*.exe/*.zip`),构建安装包的机器放好后再打包
- 缺失时构建不受影响;向导自动降级为"官网链接手动下载"模式
+119 -34
View File
@@ -2,44 +2,85 @@ import 'dart:io';
import '../utils/command_runner.dart';
/// HiTi Mac 驱动(CUPS 过滤器)内置安装:App 资源携带驱动 dmg,
/// 向导一键挂载静默安装(osascript 提权一次),已装判断 = /Library/Printers/HiTi 存在。
/// Windows 不适用(PavoApi 直打,官方驱动可选)。
/// HiTi 打印驱动内置安装:App 资源携带驱动安装包,向导一键安装。
///
/// - macOS:CUPS 过滤器 pkg(dmg 内置),已装判断 = /Library/Printers/HiTi 存在,
/// 安装 = 挂载 dmg → osascript 提权 installer → 卸载卷
/// - Windows:HiTi 官方驱动 exe(assets/drivers/*.exe 内置),UAC 提权运行;
/// 已装判断 = 我们自己的注册表标记(HKCU\Software\zax\desktop-plugin,
/// 系统级驱动安装状态无统一可查标志,安装成功后写标记最可靠)
class DriverInstall {
static const installedMarker = '/Library/Printers/HiTi';
static const _winMarkKey = r'HKCU\Software\zax\desktop-plugin';
static const _winMarkValue = 'HiTiDriverInstalled';
static bool get isSupported => Platform.isMacOS;
static bool get isSupported => Platform.isMacOS || Platform.isWindows;
/// 驱动是否已安装。
static bool isInstalled() => Directory(installedMarker).existsSync();
/// 内置驱动 dmg 的文件系统路径;未打包携带(如开发运行/产物缺失)返回 null。
///
/// Flutter assets 在 .app 内的真实路径:
/// [app]/Contents/Frameworks/App.framework/Resources/flutter_assets/[asset]
/// 从可执行文件位置反推(exe 在 [app]/Contents/MacOS/)。
static String? bundledDmgPath() {
if (!Platform.isMacOS) return null;
final exe = Platform.resolvedExecutable;
final exeDir = File(exe).parent.path; // .../Contents/MacOS
final driversDir =
'$exeDir/../Frameworks/App.framework/Resources/flutter_assets/assets/drivers';
final dir = Directory(driversDir);
if (!dir.existsSync()) return null;
final dmgs = dir
.listSync()
.whereType<File>()
.where((f) => f.path.toLowerCase().endsWith('.dmg'))
.toList();
return dmgs.isEmpty ? null : dmgs.first.path;
/// 驱动是否已安装(macOS:驱动目录;Windows:安装标记,同步场景请用 queryInstalled)。
static bool isInstalled() {
if (Platform.isMacOS) return Directory(installedMarker).existsSync();
return false; // Windows 走异步 queryInstalled(注册表查询需要子进程)
}
/// 从内置 dmg 静默安装驱动(挂载 → 找 pkg → osascript 提权 installer → 卸载卷)。
/// 提权弹窗由系统显示(一次管理员密码),超时 3 分钟。
static Future<void> installFromDmg(String dmgPath, {void Function(String line)? onLog}) async {
final log = onLog ?? (_) {};
/// Windows:异步查询安装标记。
static Future<bool> queryInstalled() async {
if (!Platform.isWindows) return isInstalled();
final out = await runCommand(
'reg',
['query', _winMarkKey, '/v', _winMarkValue],
tolerateExit: true,
);
return out.contains('0x1');
}
// 1. 只读挂载(挂载点带随机序号,必须从输出解析,不能拼名字)
/// 内置驱动安装包的文件系统路径;未打包携带返回 null。
///
/// Flutter assets 在包内的真实路径(两平台结构不同):
/// - macOS:[exe]/../Frameworks/App.framework/Resources/flutter_assets
/// - Windows:[exe]/data/flutter_assets
static String? bundledInstallerPath() {
if (Platform.isMacOS) {
final exeDir = File(Platform.resolvedExecutable).parent.path;
return _firstFile(
'$exeDir/../Frameworks/App.framework/Resources/flutter_assets/assets/drivers',
'.dmg');
}
if (Platform.isWindows) {
final exeDir = File(Platform.resolvedExecutable).parent.path;
return _firstFile('$exeDir/data/flutter_assets/assets/drivers', '.zip');
}
return null;
}
static String? _firstFile(String dirPath, String ext) {
final dir = Directory(dirPath);
if (!dir.existsSync()) return null;
final files = dir
.listSync()
.whereType<File>()
.where((f) => f.path.toLowerCase().endsWith(ext))
.toList();
return files.isEmpty ? null : files.first.path;
}
/// 静默安装(macOS)/提权安装(Windows,UAC 确认一次)。
static Future<void> install(String installerPath, {void Function(String line)? onLog}) async {
final log = onLog ?? (_) {};
if (Platform.isMacOS) {
await _installMac(installerPath, log);
return;
}
if (Platform.isWindows) {
await _installWindows(installerPath, log);
return;
}
throw UnsupportedError('当前平台不支持驱动安装');
}
// ---------------- macOS:dmg → pkg → osascript 提权 ----------------
static Future<void> _installMac(String dmgPath, void Function(String) log) async {
// 挂载点带随机序号,必须从 attach 输出解析,不能拼名字
final attachOut = await runCommand('hdiutil', ['attach', '-nobrowse', '-readonly', dmgPath],
timeoutMs: 30000);
final match = RegExp(r'/Volumes/[^\t\n]+').allMatches(attachOut);
@@ -50,7 +91,6 @@ class DriverInstall {
log('驱动卷已挂载:$mountPoint');
try {
// 2. 找 pkg
final pkg = Directory(mountPoint)
.listSync()
.whereType<File>()
@@ -61,7 +101,6 @@ class DriverInstall {
}
final pkgPath = pkg.first.path;
// 3. osascript 提权安装(AppleScript 字符串内嵌套引号用 \" 转义)
final script =
'do shell script "installer -pkg \\"$pkgPath\\" -target /" '
'with administrator privileges with prompt "安装 HiTi 打印机驱动"';
@@ -69,10 +108,56 @@ class DriverInstall {
await runCommand('osascript', ['-e', script], timeoutMs: 180000);
log('驱动安装完成');
} finally {
// 4. 卸载卷(失败不影响安装结果)
try {
await runCommand('hdiutil', ['detach', mountPoint, '-force'], tolerateExit: true);
} catch (_) {}
}
}
// ---------------- Windows:解压 zip → UAC 提权 Setup64 → 写安装标记 ----------------
static Future<void> _installWindows(String zipPath, void Function(String) log) async {
// 1. 解压到临时目录(驱动是"安装器 + 数据文件"的多文件结构,内置为 zip)
final extractDir =
'${Directory.systemTemp.path}/zax-hiti-driver-${DateTime.now().millisecondsSinceEpoch}';
log('解压驱动安装包…');
await runCommand('powershell', [
'-Command',
'Expand-Archive -Path "$zipPath" -DestinationPath "$extractDir" -Force'
], timeoutMs: 120000);
try {
// 2. 递归找安装器(优先 64 位 Setup64.exe,兜底 autorun.exe)
final setup = _findFileDeep(extractDir, 'setup64.exe') ??
_findFileDeep(extractDir, 'setup.exe') ??
_findFileDeep(extractDir, 'autorun.exe');
if (setup == null) {
throw StateError('驱动包内未找到安装程序(Setup64.exe)');
}
log('开始安装 HiTi 打印机驱动(请在 UAC 弹窗中确认,安装向导可能需要点几次"下一步")…');
// -Verb RunAs 触发 UAC;-Wait 等安装程序退出(用户取消 UAC 时 PowerShell 报错)
final script = 'Start-Process -FilePath "$setup" -Verb RunAs -Wait';
await runCommand('powershell', ['-Command', script], timeoutMs: 600000);
log('驱动安装程序已退出');
} finally {
// 3. 清理临时目录(失败不影响安装结果)
try {
await Directory(extractDir).delete(recursive: true);
} catch (_) {}
}
// 4. 写安装标记(我们自己的注册表键;0x1 = 已装)
await runCommand('reg',
['add', _winMarkKey, '/v', _winMarkValue, '/t', 'REG_DWORD', '/d', '1', '/f']);
log('已记录驱动安装状态');
}
static String? _findFileDeep(String root, String lowerName) {
final hits = Directory(root)
.listSync(recursive: true, followLinks: false)
.whereType<File>()
.where((f) => f.uri.pathSegments.last.toLowerCase() == lowerName)
.toList();
return hits.isEmpty ? null : hits.first.path;
}
}
+27 -12
View File
@@ -48,17 +48,22 @@ class _SetupWizardState extends State<SetupWizard> {
String? _driverInstallError;
PrinterStatus? _printerStatus;
/// 驱动是否已装(null=查询中;mac=驱动目录,win=注册表安装标记)
bool? _driverInstalled;
Agent get _agent => widget.agent;
@override
void initState() {
super.initState();
_checkPrinter();
_queryDriver();
// 第 2 步期间自动轮询:用户手动装驱动 / 插上打印机后页面自动刷新
// (驱动目录判断零成本;getStatus 走 lpoptions,3s 一次可接受)
_pollTimer = Timer.periodic(const Duration(seconds: 3), (_) {
if (_step == 1 && mounted && !_driverInstalling) {
_checkPrinter(silent: true);
_queryDriver();
}
});
}
@@ -71,6 +76,11 @@ class _SetupWizardState extends State<SetupWizard> {
super.dispose();
}
Future<void> _queryDriver() async {
final installed = await DriverInstall.queryInstalled();
if (mounted) setState(() => _driverInstalled = installed);
}
Future<void> _checkPrinter({bool silent = false}) async {
if (!silent) setState(() => _printerChecking = true);
try {
@@ -192,16 +202,17 @@ class _SetupWizardState extends State<SetupWizard> {
// ---------------- Step 1:打印机检测 ----------------
/// 一键安装内置驱动(mac):装完自动重检(队列要等打印机连上/系统识别后才出现)。
/// 一键安装内置驱动:装完自动重检(队列要等打印机连上/系统识别后才出现)。
Future<void> _installDriver() async {
final dmg = DriverInstall.bundledDmgPath();
if (dmg == null) return;
final installer = DriverInstall.bundledInstallerPath();
if (installer == null) return;
setState(() {
_driverInstalling = true;
_driverInstallError = null;
});
try {
await DriverInstall.installFromDmg(dmg, onLog: _agent.log.write);
await DriverInstall.install(installer, onLog: _agent.log.write);
await _queryDriver();
await _checkPrinter();
} catch (e) {
if (mounted) setState(() => _driverInstallError = '$e');
@@ -213,8 +224,8 @@ class _SetupWizardState extends State<SetupWizard> {
Widget _stepPrinter(ColorScheme scheme) {
final s = _printerStatus;
final ready = s != null && s.online;
final driverMissing = Platform.isMacOS && !DriverInstall.isInstalled();
final bundledDmg = Platform.isMacOS ? DriverInstall.bundledDmgPath() : null;
final driverMissing = _driverInstalled == false;
final bundledInstaller = DriverInstall.bundledInstallerPath();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -227,17 +238,19 @@ class _SetupWizardState extends State<SetupWizard> {
),
const SizedBox(height: 12),
// mac:驱动未装 → 首选一键安装内置驱动,其次官网链接
// 驱动未装 → 首选一键安装内置驱动,其次官网链接(mac/win 通用)
if (driverMissing) ...[
Text(
'本机尚未安装 HiTi 打印机驱动(mac 打印必需的系统组件)。',
Platform.isMacOS
? '本机尚未安装 HiTi 打印机驱动(mac 打印必需的系统组件)。'
: '本机尚未安装 HiTi 打印机驱动。直打通道需要驱动建立的 USB 连接,装好即可自动识别打印机。',
style: Theme.of(context).textTheme.bodySmall?.copyWith(height: 1.6),
),
const SizedBox(height: 8),
Row(
children: [
FilledButton.tonalIcon(
onPressed: _driverInstalling || bundledDmg == null ? null : _installDriver,
onPressed: _driverInstalling || bundledInstaller == null ? null : _installDriver,
icon: _driverInstalling
? const SizedBox(
width: 14,
@@ -246,7 +259,7 @@ class _SetupWizardState extends State<SetupWizard> {
)
: const Icon(Icons.download, size: 16),
label: Text(_driverInstalling
? '正在安装…(弹窗中输入管理员密码)'
? '正在安装…(请在系统弹窗中确认)'
: '一键安装驱动(内置安装包)'),
),
const SizedBox(width: 8),
@@ -257,7 +270,7 @@ class _SetupWizardState extends State<SetupWizard> {
),
],
),
if (bundledDmg == null)
if (bundledInstaller == null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
@@ -278,7 +291,9 @@ class _SetupWizardState extends State<SetupWizard> {
),
const SizedBox(height: 8),
Text(
'安装完成后页面会自动刷新(也可手动重新检测);随后 USB 连接打印机并开机,系统识别出现打印队列即完成。',
Platform.isMacOS
? '安装完成后:USB 连接打印机并开机,系统识别出现打印队列即完成。'
: '安装完成后 USB 连接打印机并开机,回到这里重新检测即完成。',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 4),