feat: Phase 4 收尾 + Phase 5 打包(首启向导/开机自启/URL scheme 闭环/mac dmg)
Phase 4 剩余三项: - 首次引导向导(setup/first_run + ui/setup_wizard):服务自检(含 C# 助手共存 提示)→ 打印机检测与 HiTi 驱动安装指引(官网链接/重新检测,mac 与 win 文案 分支,允许无打印机完成)→ 开机自启开关 + scheme 注册;侧栏底部常驻设置区 (自启开关 + 重新运行引导) - 开机自启(setup/autostart):mac 写 LaunchAgent(RunAtLoad 一次性,不用 KeepAlive——托盘退出不能被拉回);Windows 走 HKCU Run(reg 命令免 win32 依赖) - URL scheme 闭环:单实例唤醒端口 38210(setup/single_instance)——zaxprint:// 唤起或重复双击启动第二进程时通知常驻实例弹窗后自退,跨平台一致(弃用 app_links 的 mac 事件转发不确定性);Windows 启动时幂等注册 HKCU scheme; 前端 cardPrinter.ts 加 launchAgent()(zaxprint://open),开卡抽屉离线时显示 "启动打印助手"按钮,点击唤起后 1s×6 轮询自动上线(biz 前端改动待用户提交) Phase 5: - tool/package_macos.sh:构建 → ad-hoc 就地签名(--deep,勿加 --options=runtime: hardened runtime 库校验会因 ad-hoc 无 Team ID 拒载 framework,实测踩坑)→ hdiutil dmg;实测产出 22MB dmg + 签名后冒烟通过 - windows/installer.iss:Inno Setup 安装包脚本(快捷方式/桌面图标/开机自启 Tasks/zaxprint scheme 注册与卸载清理/HiTi SDK DLL 随 Release 目录打包) 依赖 +url_launcher +shared_preferences;13 测试全过、analyze 零问题。
This commit is contained in:
1 parent
a7cc69bb92
commit
3ecac9a302
15 files changed
+934
-51
No files matched your search
+80
-1
@@ -1,7 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../agent.dart';
|
||||
import '../setup/autostart.dart';
|
||||
import '../setup/first_run.dart';
|
||||
import 'routes.dart';
|
||||
import 'setup_wizard.dart';
|
||||
|
||||
/// 应用外壳:左侧插件菜单 + 右侧插件页面(菜单项来自路由注册表)。
|
||||
class AppShell extends StatefulWidget {
|
||||
@@ -16,9 +19,50 @@ class AppShell extends StatefulWidget {
|
||||
|
||||
class _AppShellState extends State<AppShell> {
|
||||
int _selectedIndex = 0;
|
||||
bool _autostart = false;
|
||||
bool _autostartLoaded = false;
|
||||
|
||||
Agent get _agent => widget.agent;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadAutostart();
|
||||
_maybeShowWizard();
|
||||
}
|
||||
|
||||
Future<void> _loadAutostart() async {
|
||||
final enabled = await Autostart.isEnabled();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_autostart = enabled;
|
||||
_autostartLoaded = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleAutostart(bool value) async {
|
||||
setState(() => _autostart = value);
|
||||
try {
|
||||
value ? await Autostart.enable() : await Autostart.disable();
|
||||
} catch (_) {
|
||||
if (mounted) await _loadAutostart(); // 失败回读真实状态
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _maybeShowWizard() async {
|
||||
if (await FirstRun.isDone()) return;
|
||||
if (!mounted) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => SetupWizard(agent: _agent, onFinished: _loadAutostart),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
@@ -100,8 +144,43 @@ class _AppShellState extends State<AppShell> {
|
||||
),
|
||||
|
||||
Divider(height: 1, thickness: 1, color: scheme.outlineVariant),
|
||||
|
||||
// 设置区:开机自启 + 重新运行引导
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 12),
|
||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 0),
|
||||
child: SwitchListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
value: _autostartLoaded && _autostart,
|
||||
onChanged: Autostart.isSupported ? _toggleAutostart : null,
|
||||
title: Text('开机自动启动', style: Theme.of(context).textTheme.bodySmall),
|
||||
secondary: Icon(Icons.power_settings_new,
|
||||
size: 18, color: scheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 8, 4),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: () {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => SetupWizard(agent: _agent, onFinished: _loadAutostart),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.restart_alt, size: 14),
|
||||
label: const Text('重新运行引导'),
|
||||
style: TextButton.styleFrom(
|
||||
textStyle: Theme.of(context).textTheme.bodySmall,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 10),
|
||||
child: Text(
|
||||
'v1.0.0',
|
||||
style: Theme.of(context)
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
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/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;
|
||||
PrinterStatus? _printerStatus;
|
||||
|
||||
Agent get _agent => widget.agent;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkPrinter();
|
||||
}
|
||||
|
||||
Future<void> _checkPrinter() async {
|
||||
setState(() => _printerChecking = true);
|
||||
try {
|
||||
final status = await _agent.backend.getStatus();
|
||||
if (mounted) setState(() => _printerStatus = status);
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _printerStatus = null);
|
||||
} finally {
|
||||
if (mounted) 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:打印机检测 ----------------
|
||||
|
||||
Widget _stepPrinter(ColorScheme scheme) {
|
||||
final s = _printerStatus;
|
||||
final ready = s != null && s.online;
|
||||
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),
|
||||
),
|
||||
if (!ready) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
Platform.isMacOS
|
||||
? '安装步骤:① 打开 HiTi 官网下载 macOS 驱动(HiTiCardTypeMacDrivers dmg),双击安装里面的 pkg;② USB 连接打印机并开机;③ 系统设置 → 打印机与扫描仪 确认出现 HiTi 打印机;④ 回到这里点「重新检测」。'
|
||||
: 'Windows 打印站:打开 HiTi 官网下载 CS-2xx Windows 驱动并安装,USB 连接打印机;本应用也支持免驱动的 SDK 直连模式,装完驱动或接好打印机后点「重新检测」。',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(height: 1.6),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => launchUrl(Uri.parse(_driverUrl)),
|
||||
icon: const Icon(Icons.open_in_new, size: 14),
|
||||
label: const Text('打开 HiTi 驱动下载页'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
TextButton.icon(
|
||||
onPressed: _printerChecking ? null : _checkPrinter,
|
||||
icon: const Icon(Icons.refresh, size: 16),
|
||||
label: const Text('重新检测'),
|
||||
),
|
||||
] else ...[
|
||||
const SizedBox(height: 12),
|
||||
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('完成'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user