用户实测定论: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 更新双平台放置约定
424 lines
15 KiB
Dart
424 lines
15 KiB
Dart
import 'dart:async';
|
||
import 'dart:io';
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:url_launcher/url_launcher.dart';
|
||
|
||
import '../agent.dart';
|
||
import '../server/protocol.dart';
|
||
import '../setup/autostart.dart';
|
||
import '../setup/driver_install.dart';
|
||
import '../setup/first_run.dart';
|
||
import '../setup/url_scheme.dart';
|
||
|
||
/// 首次引导向导:服务自检 → 打印机检测(含驱动引导)→ 自启/唤起配置。
|
||
/// 深色主题对话框,三步 Stepper 风格;允许无打印机完成(纯联调机)。
|
||
class SetupWizard extends StatefulWidget {
|
||
const SetupWizard({super.key, required this.agent, required this.onFinished});
|
||
|
||
final Agent agent;
|
||
final VoidCallback onFinished;
|
||
|
||
/// 便捷入口:未完成过向导时弹出。
|
||
static Future<void> showIfNeeded(BuildContext context, Agent agent) async {
|
||
if (await FirstRun.isDone()) return;
|
||
if (!context.mounted) return;
|
||
await showDialog<void>(
|
||
context: context,
|
||
barrierDismissible: false,
|
||
builder: (_) => SetupWizard(
|
||
agent: agent,
|
||
onFinished: () {},
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
State<SetupWizard> createState() => _SetupWizardState();
|
||
}
|
||
|
||
class _SetupWizardState extends State<SetupWizard> {
|
||
static const _driverUrl = 'https://www.hiti.com/support/download.aspx?MenuID=72&lang=CN';
|
||
|
||
int _step = 0;
|
||
bool _autostart = true;
|
||
bool _autostartBusy = false;
|
||
bool _printerChecking = false;
|
||
bool _driverInstalling = false;
|
||
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();
|
||
}
|
||
});
|
||
}
|
||
|
||
Timer? _pollTimer;
|
||
|
||
@override
|
||
void dispose() {
|
||
_pollTimer?.cancel();
|
||
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 {
|
||
final status = await _agent.backend.getStatus();
|
||
if (mounted) setState(() => _printerStatus = status);
|
||
} catch (_) {
|
||
if (mounted) setState(() => _printerStatus = null);
|
||
} finally {
|
||
if (mounted && !silent) setState(() => _printerChecking = false);
|
||
}
|
||
}
|
||
|
||
Future<void> _finish() async {
|
||
setState(() => _autostartBusy = true);
|
||
try {
|
||
if (_autostart && Autostart.isSupported) {
|
||
await Autostart.enable();
|
||
}
|
||
await UrlScheme.ensureRegistered();
|
||
await FirstRun.complete();
|
||
} finally {
|
||
if (mounted) setState(() => _autostartBusy = false);
|
||
}
|
||
if (mounted) {
|
||
Navigator.of(context).pop();
|
||
widget.onFinished();
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final scheme = Theme.of(context).colorScheme;
|
||
return Dialog(
|
||
backgroundColor: scheme.surfaceContainerLow,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(14),
|
||
side: BorderSide(color: scheme.outlineVariant),
|
||
),
|
||
child: ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: 520),
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(24),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
_header(scheme),
|
||
const SizedBox(height: 18),
|
||
if (_step == 0) _stepWelcome(scheme),
|
||
if (_step == 1) _stepPrinter(scheme),
|
||
if (_step == 2) _stepFinish(scheme),
|
||
const SizedBox(height: 20),
|
||
_footer(scheme),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _header(ColorScheme scheme) => Row(
|
||
children: [
|
||
Icon(Icons.extension_rounded, color: scheme.primary, size: 26),
|
||
const SizedBox(width: 10),
|
||
Text('初次使用引导', style: Theme.of(context).textTheme.titleLarge),
|
||
const Spacer(),
|
||
_stepBadge(scheme),
|
||
],
|
||
);
|
||
|
||
Widget _stepBadge(ColorScheme scheme) => Row(
|
||
children: [
|
||
for (var i = 0; i < 3; i++) ...[
|
||
Container(
|
||
width: i == _step ? 18 : 8,
|
||
height: 8,
|
||
decoration: BoxDecoration(
|
||
color: i <= _step ? scheme.primary : scheme.surfaceContainerHighest,
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
),
|
||
if (i < 2) const SizedBox(width: 6),
|
||
],
|
||
],
|
||
);
|
||
|
||
// ---------------- Step 0:服务自检 ----------------
|
||
|
||
Widget _stepWelcome(ColorScheme scheme) => Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text('资安信插件助手在本机为管理端网页提供打印等桌面能力,安装完成后保持后台运行即可,无需日常操作。',
|
||
style: Theme.of(context).textTheme.bodyMedium),
|
||
const SizedBox(height: 14),
|
||
_checkRow(
|
||
scheme,
|
||
ok: _agent.port != null,
|
||
okText: '本机服务运行中${_agent.port != null ? "(127.0.0.1:${_agent.port})" : ""}',
|
||
badText: '本机服务未启动(可能是端口被占用,请重启应用)',
|
||
),
|
||
if (Autostart.legacyCsharpAgentInstalled) ...[
|
||
const SizedBox(height: 8),
|
||
Container(
|
||
padding: const EdgeInsets.all(10),
|
||
decoration: BoxDecoration(
|
||
color: scheme.surfaceContainerHighest,
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: Text(
|
||
'检测到旧版打印助手(C# 版)在本机自启。两者协议兼容可并存,'
|
||
'如需完全切换到本应用,可在终端执行:\n'
|
||
'launchctl unload ~/Library/LaunchAgents/com.zax.cardprinteragent.plist',
|
||
style: Theme.of(context).textTheme.bodySmall?.copyWith(height: 1.5),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
);
|
||
|
||
// ---------------- Step 1:打印机检测 ----------------
|
||
|
||
/// 一键安装内置驱动:装完自动重检(队列要等打印机连上/系统识别后才出现)。
|
||
Future<void> _installDriver() async {
|
||
final installer = DriverInstall.bundledInstallerPath();
|
||
if (installer == null) return;
|
||
setState(() {
|
||
_driverInstalling = true;
|
||
_driverInstallError = null;
|
||
});
|
||
try {
|
||
await DriverInstall.install(installer, onLog: _agent.log.write);
|
||
await _queryDriver();
|
||
await _checkPrinter();
|
||
} catch (e) {
|
||
if (mounted) setState(() => _driverInstallError = '$e');
|
||
} finally {
|
||
if (mounted) setState(() => _driverInstalling = false);
|
||
}
|
||
}
|
||
|
||
Widget _stepPrinter(ColorScheme scheme) {
|
||
final s = _printerStatus;
|
||
final ready = s != null && s.online;
|
||
final driverMissing = _driverInstalled == false;
|
||
final bundledInstaller = DriverInstall.bundledInstallerPath();
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_checkRow(
|
||
scheme,
|
||
ok: ready,
|
||
loading: _printerChecking,
|
||
okText: '打印机就绪${s != null && s.model.isNotEmpty ? "(${s.model})" : ""}',
|
||
badText: s == null ? '未检测到打印队列' : (s.statusText.isEmpty ? '打印机未就绪' : s.statusText),
|
||
),
|
||
const SizedBox(height: 12),
|
||
|
||
// 驱动未装 → 首选一键安装内置驱动,其次官网链接(mac/win 通用)
|
||
if (driverMissing) ...[
|
||
Text(
|
||
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 || bundledInstaller == null ? null : _installDriver,
|
||
icon: _driverInstalling
|
||
? const SizedBox(
|
||
width: 14,
|
||
height: 14,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
)
|
||
: const Icon(Icons.download, size: 16),
|
||
label: Text(_driverInstalling
|
||
? '正在安装…(请在系统弹窗中确认)'
|
||
: '一键安装驱动(内置安装包)'),
|
||
),
|
||
const SizedBox(width: 8),
|
||
TextButton.icon(
|
||
onPressed: () => launchUrl(Uri.parse(_driverUrl)),
|
||
icon: const Icon(Icons.open_in_new, size: 14),
|
||
label: const Text('改为手动下载'),
|
||
),
|
||
],
|
||
),
|
||
if (bundledInstaller == null)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 4),
|
||
child: Text(
|
||
'(当前构建未内置驱动安装包,请用官网链接手动安装)',
|
||
style: Theme.of(context).textTheme.bodySmall,
|
||
),
|
||
),
|
||
if (_driverInstallError != null)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 6),
|
||
child: Text(
|
||
'安装失败:$_driverInstallError',
|
||
style: Theme.of(context)
|
||
.textTheme
|
||
.bodySmall
|
||
?.copyWith(color: scheme.error),
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
Platform.isMacOS
|
||
? '安装完成后:USB 连接打印机并开机,系统识别出现打印队列即完成。'
|
||
: '安装完成后 USB 连接打印机并开机,回到这里重新检测即完成。',
|
||
style: Theme.of(context).textTheme.bodySmall,
|
||
),
|
||
const SizedBox(height: 4),
|
||
TextButton.icon(
|
||
onPressed: _printerChecking ? null : () => _checkPrinter(),
|
||
icon: const Icon(Icons.refresh, size: 16),
|
||
label: const Text('重新检测'),
|
||
),
|
||
]
|
||
|
||
// 驱动已装但打印机未就绪 → 引导连接/添加队列
|
||
else if (!ready) ...[
|
||
Text(
|
||
Platform.isMacOS
|
||
? '驱动已安装。请确认:① 打印机 USB 已连接并开机;② 系统设置 → 打印机与扫描仪 里出现 HiTi 打印机(没有的话点下方「+」添加,选 HiTi CS-200e);③ 回到这里重新检测。'
|
||
: '请先:① 用 USB 线连接打印机并开机,点「重新检测」——本应用通过 SDK 直连打印机,通常无需安装驱动;② 若反复检测不到打印机,再考虑打开 HiTi 官网安装 CS-2xx Windows 驱动(可选兜底)。',
|
||
style: Theme.of(context).textTheme.bodySmall?.copyWith(height: 1.6),
|
||
),
|
||
const SizedBox(height: 8),
|
||
if (!Platform.isMacOS)
|
||
Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: Text(
|
||
'驱动下载(可选兜底)',
|
||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||
color: scheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
),
|
||
Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: TextButton.icon(
|
||
onPressed: () => launchUrl(Uri.parse(_driverUrl)),
|
||
icon: const Icon(Icons.open_in_new, size: 14),
|
||
label: Text(Platform.isMacOS ? '打开 HiTi 驱动下载页' : 'HiTi 驱动下载页(可选)'),
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
TextButton.icon(
|
||
onPressed: _printerChecking ? null : _checkPrinter,
|
||
icon: const Icon(Icons.refresh, size: 16),
|
||
label: const Text('重新检测'),
|
||
),
|
||
] else ...[
|
||
Text('打印机已连接,可以正常接收管理端提交的卡面打印。',
|
||
style: Theme.of(context).textTheme.bodySmall),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
|
||
// ---------------- Step 2:完成配置 ----------------
|
||
|
||
Widget _stepFinish(ColorScheme scheme) => Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
SwitchListTile(
|
||
contentPadding: EdgeInsets.zero,
|
||
value: _autostart,
|
||
onChanged: Autostart.isSupported
|
||
? (v) => setState(() => _autostart = v)
|
||
: null,
|
||
title: const Text('开机自动启动'),
|
||
subtitle: Text(
|
||
Autostart.isSupported ? '登录系统后自动在后台运行(可在左侧菜单底部随时修改)' : '当前平台不支持',
|
||
style: Theme.of(context).textTheme.bodySmall,
|
||
),
|
||
),
|
||
const SizedBox(height: 6),
|
||
_checkRow(
|
||
scheme,
|
||
ok: true,
|
||
okText: '网页唤起支持(zaxprint:// 协议)已启用',
|
||
badText: '',
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'全部完成。关闭窗口将最小化到托盘,服务持续运行;退出请通过托盘菜单。',
|
||
style: Theme.of(context).textTheme.bodySmall,
|
||
),
|
||
],
|
||
);
|
||
|
||
// ---------------- 公共组件 ----------------
|
||
|
||
Widget _checkRow(ColorScheme scheme,
|
||
{required bool ok, required String okText, required String badText, bool loading = false}) {
|
||
final color = ok ? scheme.primary : const Color(0xFFD97706);
|
||
return Row(
|
||
children: [
|
||
if (loading)
|
||
const SizedBox(
|
||
width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))
|
||
else
|
||
Icon(ok ? Icons.check_circle : Icons.info_outline, size: 18, color: color),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(
|
||
ok ? okText : badText,
|
||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: color),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _footer(ColorScheme scheme) => Row(
|
||
children: [
|
||
if (_step > 0)
|
||
TextButton(
|
||
onPressed: () => setState(() => _step--),
|
||
child: const Text('上一步'),
|
||
),
|
||
const Spacer(),
|
||
if (_step < 2)
|
||
FilledButton(
|
||
onPressed: () => setState(() => _step++),
|
||
child: Text(_step == 0 ? '下一步' : '跳过,下一步'),
|
||
)
|
||
else
|
||
FilledButton(
|
||
onPressed: _autostartBusy ? null : _finish,
|
||
child: const Text('完成'),
|
||
),
|
||
],
|
||
);
|
||
}
|