Files
desktop-plugin/lib/setup/driver_install.dart
T
dzc 9ece6cc294 fix(windows): 驱动安装假成功——InstallShield 壳提前退出骗过 -Wait,改 pnputil 实时校验
用户实测:一键安装日志显示 12 秒"装完"但打印机仍离线。根因:HiTi 的
Setup64.exe 是自解压壳(InstallShield 类),Start-Process -Wait 等的是壳进程,
内层安装向导未真正完成即返回;且旧的注册表标记机制把这次假安装记成了"已装"。

修复:
- 安装流程尾部增加 pnputil /enum-drivers 校验(匹配 cs2xxhcd/htitahcd/hiti),
  未发现驱动即报"校验未通过"(引导重试或手动运行 Setup64),不再盲写标记
- queryInstalled 改为 pnputil 实时校验驱动商店(唯一真源,废弃注册表标记——
  假安装误写后向导会错误地不再显示安装按钮)
- 成功日志改为提示"拔插 USB 重新枚举设备后再检测"
2026-09-21 16:46:31 +08:00

171 lines
7.4 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:io';
import '../utils/command_runner.dart';
/// HiTi 打印驱动内置安装:App 资源携带驱动安装包,向导一键安装。
///
/// - macOS:CUPS 过滤器 pkg(dmg 内置),已装判断 = /Library/Printers/HiTi 存在,
/// 安装 = 挂载 dmg → osascript 提权 installer → 卸载卷
/// - Windows:HiTi 官方驱动整目录 zip 内置,已装判断 = pnputil 枚举驱动商店
/// (实时校验,安装器壳提前退出不会误判),安装 = 解压 → UAC 提权 Setup64 →
/// pnputil 校验通过才算成功
class DriverInstall {
static const installedMarker = '/Library/Printers/HiTi';
static bool get isSupported => Platform.isMacOS || Platform.isWindows;
/// 驱动是否已安装(macOS:驱动目录;Windows:安装标记,同步场景请用 queryInstalled)。
static bool isInstalled() {
if (Platform.isMacOS) return Directory(installedMarker).existsSync();
return false; // Windows 走异步 queryInstalled(注册表查询需要子进程)
}
/// Windows:pnputil 实时校验驱动商店(安装器是自解压壳、可能假成功,
/// 注册表标记会被失败的安装误写,故以系统驱动商店为唯一真源)。
static Future<bool> queryInstalled() async {
if (!Platform.isWindows) return isInstalled();
final drivers = await runCommand('pnputil', ['/enum-drivers'],
tolerateExit: true, timeoutMs: 60000);
final lower = drivers.toLowerCase();
return lower.contains('cs2xxhcd') || lower.contains('htitahcd') || lower.contains('hiti');
}
/// 内置驱动安装包的文件系统路径;未打包携带返回 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);
if (match.isEmpty) {
throw StateError('驱动 dmg 挂载失败');
}
final mountPoint = match.last.group(0)!;
log('驱动卷已挂载:$mountPoint');
try {
final pkg = Directory(mountPoint)
.listSync()
.whereType<File>()
.where((f) => f.path.toLowerCase().endsWith('.pkg'))
.toList();
if (pkg.isEmpty) {
throw StateError('驱动 dmg 内未找到 .pkg 安装包');
}
final pkgPath = pkg.first.path;
final script =
'do shell script "installer -pkg \\"$pkgPath\\" -target /" '
'with administrator privileges with prompt "安装 HiTi 打印机驱动"';
log('开始安装驱动(请在系统弹窗中输入管理员密码)…');
await runCommand('osascript', ['-e', script], timeoutMs: 180000);
log('驱动安装完成');
} finally {
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 弹窗中确认,安装向导可能需要点几次"下一步")…');
// 注意:HiTi 的 Setup64 是自解压壳(InstallShield 类),-Wait 等的是壳进程,
// 内层安装向导可能仍在运行——安装是否成功必须以后续的驱动校验为准
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. 校验:驱动是否真的进了驱动商店(pnputil 枚举,匹配 htitahcd/CS2xx)
await Future<void>.delayed(const Duration(seconds: 3)); // 给安装收尾一点时间
final drivers = await runCommand('pnputil', ['/enum-drivers'],
tolerateExit: true, timeoutMs: 60000);
final lower = drivers.toLowerCase();
final installed = lower.contains('cs2xxhcd') || lower.contains('htitahcd') || lower.contains('hiti');
if (!installed) {
throw StateError(
'驱动校验未通过:系统中未发现 HiTi 驱动(安装器可能未真正完成,请重试,'
'或手动运行驱动包 CS2xx 目录内的 Setup64.exe 完成安装)');
}
log('校验通过:HiTi 驱动已装入系统');
log('驱动安装完成——请拔掉打印机 USB 重新插上(驱动绑定需要设备重新枚举),然后重新检测');
}
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;
}
}