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:
dzc committed 2026-09-21 13:51:42 +08:00
1 parent a7cc69bb92
commit 3ecac9a302
15 files changed
+934 -51

No files matched your search

+17
View File
@@ -1,7 +1,11 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:window_manager/window_manager.dart';
import 'agent.dart';
import 'setup/single_instance.dart';
import 'setup/url_scheme.dart';
import 'ui/app_shell.dart';
import 'ui/app_tray.dart';
@@ -12,6 +16,17 @@ Future<void> main(List<String> args) async {
WidgetsFlutterBinding.ensureInitialized();
await windowManager.ensureInitialized();
// 单实例:zaxprint:// 唤起或重复双击会启动第二进程——通知常驻实例弹窗后自己退出
final isPrimary = await SingleInstance.acquire(
onWake: () async {
await windowManager.show();
await windowManager.focus();
},
);
if (!isPrimary) {
exit(0);
}
final agent = Agent(AgentOptions.parse(args));
String? startError;
try {
@@ -20,6 +35,8 @@ Future<void> main(List<String> args) async {
startError = '$e';
}
await UrlScheme.ensureRegistered(); // Windows 运行时注册 zaxprint://;mac 由 Info.plist 声明
final appTray = AppTray(agent: agent);
await appTray.start();
+101
View File
@@ -0,0 +1,101 @@
import 'dart:io';
import '../utils/command_runner.dart';
/// 开机自启:mac 写 LaunchAgent(RunAtLoad 一次性——托盘退出不能被拉回,故不用 KeepAlive);
/// Windows 写 HKCU Run 注册表键(reg 命令实现,免 win32 依赖)。
/// 自启指向当前可执行文件(Platform.resolvedExecutable),以安装位置为准。
class Autostart {
static const macLabel = 'com.zax.desktop-plugin';
static const winRunName = 'ZaxDesktopPlugin';
static bool get isSupported => Platform.isMacOS || Platform.isWindows;
static String? get _macPlistPath {
final home = Platform.environment['HOME'];
if (home == null) return null;
return '$home/Library/LaunchAgents/$macLabel.plist';
}
/// 自启是否已开启。
static Future<bool> isEnabled() async {
if (Platform.isMacOS) {
final path = _macPlistPath;
return path != null && File(path).existsSync();
}
if (Platform.isWindows) {
final r = await runCommand(
'reg',
['query', r'HKCU\Software\Microsoft\Windows\CurrentVersion\Run', '/v', winRunName],
tolerateExit: true,
);
return r.contains(winRunName);
}
return false;
}
/// 开启自启(mac 顺带把 C# 助手的 LaunchAgent 状态返回,供向导提示端口关系)。
static Future<void> enable() async {
if (Platform.isMacOS) {
final path = _macPlistPath;
if (path == null) throw StateError('无法定位用户目录');
final exe = Platform.resolvedExecutable;
await Directory(File(path).parent.path).create(recursive: true);
await File(path).writeAsString('''
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>$macLabel</string>
<key>ProgramArguments</key>
<array>
<string>$exe</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>LaunchOnlyOnce</key>
<true/>
</dict>
</plist>
''');
return;
}
if (Platform.isWindows) {
final exe = Platform.resolvedExecutable;
await runCommand('reg', [
'add', r'HKCU\Software\Microsoft\Windows\CurrentVersion\Run',
'/v', winRunName, '/t', 'REG_SZ', '/d', '"$exe"', '/f',
]);
return;
}
throw UnsupportedError('当前平台不支持开机自启');
}
static Future<void> disable() async {
if (Platform.isMacOS) {
final path = _macPlistPath;
if (path != null && File(path).existsSync()) {
await File(path).delete();
}
return;
}
if (Platform.isWindows) {
await runCommand(
'reg',
['delete', r'HKCU\Software\Microsoft\Windows\CurrentVersion\Run', '/v', winRunName, '/f'],
tolerateExit: true, // 不存在时也算关闭成功
);
return;
}
throw UnsupportedError('当前平台不支持开机自启');
}
/// C# 版助手的 LaunchAgent 是否在(本机灰度共存提示用;mac only)。
static bool get legacyCsharpAgentInstalled {
if (!Platform.isMacOS) return false;
final home = Platform.environment['HOME'];
return home != null &&
File('$home/Library/LaunchAgents/com.zax.cardprinteragent.plist').existsSync();
}
}
+22
View File
@@ -0,0 +1,22 @@
import 'package:shared_preferences/shared_preferences.dart';
/// 首次引导向导完成标记(shared_preferences 持久化)。
class FirstRun {
static const _key = 'first_run_done';
static Future<bool> isDone() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_key) ?? false;
}
static Future<void> complete() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_key, true);
}
/// 重新运行向导(设置入口用)。
static Future<void> reset() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_key, false);
}
}
+50
View File
@@ -0,0 +1,50 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
/// 单实例 + 唤醒端口:应用只允许一个常驻实例。
///
/// zaxprint:// 唤起或用户再次双击图标时,OS 会启动第二个进程——第二实例连上
/// 唤醒端口发送 wake 后立即退出,常驻实例收到后把主窗口带到前台。
/// (比 app_links 的方案更跨平台一致:mac/win 都只依赖回环 socket。)
class SingleInstance {
static const int wakePort = 38210;
/// 尝试成为主实例。
///
/// 返回 true = 本实例是主实例(已开始监听唤醒,[onWake] 在收到唤醒时回调);
/// 返回 false = 已有常驻实例(已通知其显示窗口),调用方应当退出进程。
static Future<bool> acquire({void Function()? onWake}) async {
try {
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, wakePort);
server.listen((socket) {
socket
.cast<List<int>>()
.transform(utf8.decoder)
.listen((message) {
if (message.contains('wake')) {
onWake?.call();
}
}, onDone: () {
socket.destroy();
});
}, onError: (_) {});
return true;
} on SocketException {
// 端口被占:已有常驻实例,通知它显示窗口
try {
final socket = await Socket.connect(
InternetAddress.loopbackIPv4,
wakePort,
timeout: const Duration(seconds: 2),
);
socket.add(utf8.encode('wake'));
await socket.flush();
await socket.close();
} catch (_) {
// 唤醒端口被非本应用进程占用等异常:放弃唤醒,按第二实例退出
}
return false;
}
}
}
+37
View File
@@ -0,0 +1,37 @@
import 'dart:io';
import '../utils/command_runner.dart';
/// zaxprint:// URL scheme 注册:
/// - macOS:Info.plist CFBundleURLTypes 已声明(构建即生效),无需运行时处理
/// - Windows:写 HKCU\Software\Classes\zaxprint(用户级,免管理员),App 启动时 ensure
class UrlScheme {
static const scheme = 'zaxprint';
static bool get needsRuntimeRegister => Platform.isWindows;
/// Windows:确保 scheme 已注册到当前可执行文件(每次启动幂等调用,路径变了会刷新)。
static Future<void> ensureRegistered() async {
if (!Platform.isWindows) return;
final exe = Platform.resolvedExecutable;
final root = r'HKCU\Software\Classes' '\\$scheme';
await runCommand('reg', ['add', root, '/ve', '/t', 'REG_SZ', '/d', 'URL:资安信插件助手唤起', '/f']);
await runCommand('reg', ['add', root, '/v', 'URL Protocol', '/t', 'REG_SZ', '/d', '', '/f']);
await runCommand('reg', [
'add', r'HKCU\Software\Classes' '\\$scheme' r'\shell\open\command',
'/ve', '/t', 'REG_SZ', '/d', '"$exe" "%1"', '/f',
]);
}
/// Windows:注册是否指向当前可执行文件(向导自检展示用)。
static Future<bool> isRegisteredToSelf() async {
if (!Platform.isWindows) return true; // mac 由 Info.plist 保证
final exe = Platform.resolvedExecutable;
final out = await runCommand(
'reg',
['query', r'HKCU\Software\Classes' '\\$scheme' r'\shell\open\command', '/ve'],
tolerateExit: true,
);
return out.contains(exe);
}
}
+80 -1
View File
@@ -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)
+296
View File
@@ -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('完成'),
),
],
);
}