feat: 多端邀请接入

This commit is contained in:
2026-01-19 07:21:28 -08:00
parent b441346630
commit 8e48a6ff8a
19 changed files with 476 additions and 71 deletions
+141
View File
@@ -25,11 +25,15 @@ import 'package:kaer_with_panels/app/widgets/dialogs/hi_dialog.dart';
import 'package:kaer_with_panels/app/services/api_service/kr_auth_api.dart';
import 'package:kaer_with_panels/app/services/kr_subscribe_service.dart';
import 'package:kaer_with_panels/app/utils/kr_common_util.dart';
import 'package:kaer_with_panels/app/utils/kr_device_util.dart';
import 'package:openinstall_flutter_plugin/openinstall_flutter_plugin.dart';
import 'dart:io' show Platform;
class KRAppRunData {
static final KRAppRunData _instance = KRAppRunData._internal();
static const String _keyUserInfo = 'USER_INFO';
static const bool inviteDebugMode = true;
/// 登录token
String? kr_token;
@@ -61,6 +65,9 @@ class KRAppRunData {
// 需要被监听的属性,用 obs 包装
final kr_isLogin = false.obs;
// 存储临时待绑定的邀请码(处理唤醒数据比登录完成早的情况)
String? _kr_pendingInviteCode;
KRAppRunData._internal();
factory KRAppRunData() => _instance;
@@ -447,6 +454,16 @@ class KRAppRunData {
kr_isLogin.value = true;
print('✅ 已标记为登录状态');
// 静默邀请绑定
if (inviteDebugMode) {
// Debug 模式下等待调试弹窗完成,避免页面跳转
await _kr_handleSilentInvitation();
} else {
// 正式环境异步执行,不阻塞主流程
_kr_handleSilentInvitation();
}
_logStepTiming('设备登录完成');
return true;
},
@@ -496,6 +513,9 @@ class KRAppRunData {
KRLogUtil.kr_i('✅ Token和账号验证通过,设置登录状态为true', tag: 'AppRunData');
KRLogUtil.kr_i('📊 恢复账号: ${kr_account.value}', tag: 'AppRunData');
kr_isLogin.value = true;
// 🔧 新增:恢复登录状态后也尝试检测一次静默邀请(重要:针对已安装后启动的情况)
_kr_handleSilentInvitation();
} else {
// 账号信息为空,清理旧数据
KRLogUtil.kr_w('⚠️ 账号信息为空,清理该条用户数据', tag: 'AppRunData');
@@ -633,4 +653,125 @@ class KRAppRunData {
KRLogUtil.kr_e('📚 [AppRunData] 错误堆栈: $stackTrace', tag: 'AppRunData');
}
}
/// 处理静默邀请
Future<void> _kr_handleSilentInvitation() async {
KRLogUtil.kr_i('🚀 开始处理静默邀请...', tag: 'AppRunData');
String? inviteCode;
// 1. 先检查是否有之前通过唤醒/安装暂存的邀请码
if (_kr_pendingInviteCode != null && _kr_pendingInviteCode!.isNotEmpty) {
inviteCode = _kr_pendingInviteCode;
KRLogUtil.kr_i('📎 使用暂存的待绑定邀请码: $inviteCode', tag: 'AppRunData');
}
// 2. 如果没有暂存码,尝试从平台环境获取
if (inviteCode == null || inviteCode!.isEmpty) {
try {
if (Platform.isMacOS || Platform.isWindows) {
inviteCode = await KRDeviceUtil().kr_getDesktopInviteCode();
} else if (Platform.isAndroid || Platform.isIOS) {
final Completer<String?> completer = Completer<String?>();
OpeninstallFlutterPlugin().install((data) async {
final code = kr_parseInviteCodeFromData(data);
KRLogUtil.kr_i('收到 OpenInstall 安装数据: $data, 解析出邀请码: $code', tag: 'AppRunData');
if (!completer.isCompleted) completer.complete(code);
});
inviteCode = await completer.future
.timeout(const Duration(seconds: 8), onTimeout: () => null);
}
} catch (e) {
KRLogUtil.kr_e('获取静默邀请码异常: $e', tag: 'AppRunData');
}
}
if (inviteCode != null && inviteCode!.isNotEmpty) {
KRLogUtil.kr_i('🔍 最终识别到邀请码: $inviteCode', tag: 'AppRunData');
if (inviteDebugMode) {
// Debug 模式下弹出对话框确认
final bool isDesktop = Platform.isMacOS || Platform.isWindows;
await HIDialog.show(
title: isDesktop ? '调试:唤醒识别到邀请码' : '调试:邀请码绑定确认',
message: isDesktop
? '桌面端识别到邀请码:$inviteCode\n是否进行绑定?'
: '识别到邀请码:$inviteCode\n是否进行绑定?',
confirmText: isDesktop ? '绑定' : '确认绑定',
cancelText: isDesktop ? '跳过' : '取消',
onConfirm: () async {
await _kr_performInviteBinding(inviteCode!);
_kr_pendingInviteCode = null; // 绑定后清除
},
onCancel: () {
_kr_pendingInviteCode = null; // 取消也清除,避免重复弹窗
},
);
} else {
// 正式环境静默绑定
await _kr_performInviteBinding(inviteCode!);
_kr_pendingInviteCode = null; // 绑定后清除
}
} else {
KRLogUtil.kr_i('⚠️ 未识别到有效的邀请码,跳外静默绑定', tag: 'AppRunData');
}
}
/// 执行邀请码绑定请求
Future<void> _kr_performInviteBinding(String inviteCode) async {
KRLogUtil.kr_i('🚀 准备执行邀请码绑定: $inviteCode', tag: 'AppRunData');
final result =
await KRUserApi().hi_inviteCode(inviteCode, isSilentInvite: true);
result.fold(
(error) => KRLogUtil.kr_w('❌ 邀请绑定失败: ${error.msg}', tag: 'AppRunData'),
(_) => KRLogUtil.kr_i('✅ 邀请绑定成功', tag: 'AppRunData'),
);
}
/// 公开方法:直接处理 OpenInstall 返回的原始数据(用于唤醒等场景)
Future<void> kr_handleOpenInstallData(Map<dynamic, dynamic> data) async {
final code = kr_parseInviteCodeFromData(data);
if (code != null && code.isNotEmpty) {
KRLogUtil.kr_i('🔗 收到 OpenInstall 原始参数并触发解析: $code', tag: 'AppRunData');
// 暂存该邀请码
_kr_pendingInviteCode = code;
// 如果当前已经是登录状态,则由于是唤醒(Hot Start)触发,直接按业务逻辑处理
if (kr_isLogin.value) {
KRLogUtil.kr_i('✅ 用户已登录,立即处理唤醒绑定', tag: 'AppRunData');
if (inviteDebugMode) {
await HIDialog.show(
title: '调试:唤醒识别到邀请码',
message: '唤醒数据解析到邀请码:$code\n是否进行绑定?',
confirmText: '绑定',
cancelText: '跳过',
onConfirm: () async {
await _kr_performInviteBinding(code);
_kr_pendingInviteCode = null;
},
onCancel: () => _kr_pendingInviteCode = null,
);
} else {
_kr_performInviteBinding(code).then((_) => _kr_pendingInviteCode = null);
}
} else {
KRLogUtil.kr_i('⏳ 用户未登录,已暂存邀请码,等待登录完成后自动绑定', tag: 'AppRunData');
}
}
}
/// 从 OpenInstall 数据中解析邀请码
String? kr_parseInviteCodeFromData(Map<dynamic, dynamic> data) {
try {
if (data.containsKey('bindData')) {
final bindDataStr = data['bindData'] as String?;
if (bindDataStr != null && bindDataStr.isNotEmpty) {
final Map<String, dynamic> bindData = jsonDecode(bindDataStr);
return bindData['inviteCode']?.toString();
}
}
} catch (e) {
KRLogUtil.kr_e('解析 OpenInstall 数据中邀请码失败: $e', tag: 'AppRunData');
}
return null;
}
}
@@ -1,5 +1,6 @@
import 'package:get/get.dart';
import 'dart:convert';
import 'package:openinstall_flutter_plugin/openinstall_flutter_plugin.dart';
import 'dart:io' show Platform, SocketException;
import 'dart:math';
@@ -165,6 +166,9 @@ class KRSplashController extends GetxController {
}),
]);
// 静默邀请初始化(在主流程完成后进行,不影响启动速度)
_kr_initOpenInstall();
_initLog.logPhaseEnd('主初始化流程', success: true);
} on TimeoutException catch (e) {
// 🔧 P2优化:超时错误提供更友好的提示
@@ -558,6 +562,22 @@ class KRSplashController extends GetxController {
_kr_initialize();
}
/// 初始化 OpenInstall
void _kr_initOpenInstall() {
if (Platform.isAndroid || Platform.isIOS) {
try {
KRLogUtil.kr_i('🚀 初始化 OpenInstall...', tag: 'SplashController');
OpeninstallFlutterPlugin().init((data) async {
KRLogUtil.kr_i('收到 OpenInstall 唤醒数据: $data', tag: 'SplashController');
// 处理唤醒时的邀请绑定
KRAppRunData().kr_handleOpenInstallData(data);
});
} catch (e) {
KRLogUtil.kr_e('OpenInstall 初始化失败: $e', tag: 'SplashController');
}
}
}
// 🔧 P3优化:跳过初始化,直接进入主页
void kr_skipInitialization() {
KRLogUtil.kr_i('⏭️ 用户选择跳过初始化', tag: 'SplashController');
+21 -20
View File
@@ -131,9 +131,11 @@ class HttpUtil {
}
}
/// request请求:T为转换的实体类, path:请求地址,query:请求参数, method: 请求方法, isShowLoading(可选): 是否显示加载中的状态,默认true显示, false为不显示
/// request请求:T为转换的实体类, path:请求地址,query:请求参数, method: 请求方法, isShowLoading(可选): 是否显示加载中的状态,默认true显示, false为不显示, silentInvite: 是否为静默邀请
Future<BaseResponse<T>> request<T>(String path, Map<String, dynamic> params,
{HttpMethod method = HttpMethod.POST, bool isShowLoading = true}) async {
{HttpMethod method = HttpMethod.POST,
bool isShowLoading = true,
bool isSilentInvite = false}) async {
try {
// 每次请求前更新baseUrl,确保使用最新的域名
updateBaseUrl();
@@ -148,43 +150,40 @@ class HttpUtil {
// 初始化请求头
final headers = _initHeader('signature', 'userId', 'token');
if (isSilentInvite) {
headers['X-Client-Mode'] = 'invite_silent';
}
final options = Options(
contentType: "application/json",
headers: headers,
extra: {'silentInvite': isSilentInvite},
);
Response<Map<String, dynamic>> responseTemp;
if (method == HttpMethod.GET) {
responseTemp = await _dio.get<Map<String, dynamic>>(
path,
queryParameters: map,
options: Options(
contentType: "application/json",
headers: headers, // 添加请求头
),
options: options,
);
} else if (method == HttpMethod.DELETE) {
responseTemp = await _dio.delete<Map<String, dynamic>>(
path,
data: map,
options: Options(
contentType: "application/json",
headers: headers, // 添加请求头
),
options: options,
);
} else if (method == HttpMethod.PUT) {
responseTemp = await _dio.put<Map<String, dynamic>>(
path,
data: map,
options: Options(
contentType: "application/json",
headers: headers, // 添加请求头
),
options: options,
);
} else {
responseTemp = await _dio.post<Map<String, dynamic>>(
path,
data: map,
options: Options(
contentType: "application/json",
headers: headers, // 添加请求头
),
options: options,
);
}
@@ -237,7 +236,8 @@ class HttpUtil {
msg = '${msg.isNotEmpty ? msg : 'unknown'} ($_pathOnly)';
final _ua =
(err.requestOptions.extra['__unknown_attempts'] as int?) ?? 0;
if (_ua >= 2) {
final bool isSilent = err.requestOptions.extra['silentInvite'] ?? false;
if (_ua >= 2 && !isSilent) {
KRCommonUtil.kr_showToast('请求失败($_pathOnly', timeout: 3500);
}
}
@@ -374,7 +374,8 @@ class _KRSimpleHttpInterceptor extends Interceptor {
return;
} else {
final now = DateTime.now().millisecondsSinceEpoch;
if (!(_lastPath == path && (now - _lastTsMs) < 2000)) {
final bool isSilent = err.requestOptions.extra['silentInvite'] ?? false;
if (!(_lastPath == path && (now - _lastTsMs) < 2000) && !isSilent) {
_lastPath = path;
_lastTsMs = now;
KRCommonUtil.kr_showToast('请求失败($path', timeout: 3500);
+8
View File
@@ -39,6 +39,14 @@ class AppPages {
static const INITIAL = Routes.KR_SPLASH;
static final routes = [
GetPage(
name: '/',
page: () => SwipeWrapper.detect(() => const KRSplashView()),
binding: KRSplashBinding(),
popGesture: false,
transition: Transition.fade,
transitionDuration: const Duration(milliseconds: 500),
),
GetPage(
name: Routes.KR_SPLASH,
page: () => SwipeWrapper.detect(() => const KRSplashView()),
@@ -194,7 +194,7 @@ class KRUserApi {
}
/// 绑定样式
Future<Either<HttpError, void>> hi_inviteCode(String inviteCode) async {
Future<Either<HttpError, void>> hi_inviteCode(String inviteCode, {bool isSilentInvite = false}) async {
final Map<String, dynamic> data = <String, dynamic>{};
// 将字符串 ID 转换为整数
@@ -209,7 +209,8 @@ class KRUserApi {
Api.hi_invite_code,
data,
method: HttpMethod.POST,
isShowLoading: true,
isShowLoading: !isSilentInvite,
isSilentInvite: isSilentInvite,
);
if (!baseResponse.isSuccess) {
+165 -1
View File
@@ -76,4 +76,168 @@ class KRDeviceUtil {
_kr_cachedDeviceId = null;
_kr_storage.kr_deleteData(key: _kr_deviceIdKey);
}
}
/// 从桌面平台提取邀请码 (深度检测)
/// 1. 检测执行路径 (支持重命名的 .app 或二进制)
/// 2. (MacOS) 检测挂载源 (支持重命名的 DMG)
/// 3. (MacOS) 检测来源元数据 (支持从下载链接中识别)
Future<String> kr_getDesktopInviteCode() async {
if (!Platform.isMacOS && !Platform.isWindows) return '';
try {
final String executablePath = Platform.resolvedExecutable;
KRLogUtil.kr_i('🔍 [DEBUG] 桌面端开始深度解析邀请码, 当前路径: $executablePath', tag: 'DeviceUtil');
// 策略 1: 直接匹配路径 (最快)
String? code = _kr_extractCode(executablePath);
if (code != null) return code;
if (Platform.isMacOS) {
// 策略 2: 如果在 /Volumes 下运行,尝试找 DMG 原文件名
if (executablePath.contains('/Volumes/')) {
code = await _kr_getInviteCodeFromHdiutil(executablePath);
if (code != null) {
KRLogUtil.kr_i('🎯 从 hdiutil (挂载源) 获取到邀请码: $code', tag: 'DeviceUtil');
return code;
}
}
// 策略 3: 检查文件的来源元数据 (kMDItemWhereFroms)
code = await _kr_getInviteCodeFromMetadata(executablePath);
if (code != null) {
KRLogUtil.kr_i('🎯 从 mdls (文件元数据) 获取到邀请码: $code', tag: 'DeviceUtil');
return code;
}
// 策略 4: 在下载目录下寻找最近的带有 ic- 的 DMG
code = await _kr_searchDownloadsForInviteCode();
if (code != null) {
KRLogUtil.kr_i('🎯 从下载目录搜索获取到邀请码: $code', tag: 'DeviceUtil');
return code;
}
KRLogUtil.kr_i('⚠️ 深度检测完成,未发现有效的邀请码标识', tag: 'DeviceUtil');
}
} catch (e) {
KRLogUtil.kr_e('解析桌面邀请码异常: $e', tag: 'DeviceUtil');
}
return '';
}
/// (MacOS) 在下载目录下搜寻最近的、符合命名规范的 DMG
Future<String?> _kr_searchDownloadsForInviteCode() async {
try {
final String home = Platform.environment['HOME'] ?? '';
if (home.isEmpty) return null;
final Directory downloads = Directory('$home/Downloads');
if (!await downloads.exists()) return null;
final List<FileSystemEntity> files = await downloads.list().toList();
String? bestMatch;
DateTime? latestDate;
for (var file in files) {
if (file is File && file.path.contains('ic-') && (file.path.endsWith('.dmg') || file.path.endsWith('.exe'))) {
// 提取代码
final code = _kr_extractCode(file.path);
if (code != null) {
final stat = await file.stat();
if (latestDate == null || stat.modified.isAfter(latestDate)) {
latestDate = stat.modified;
bestMatch = code;
}
}
}
}
return bestMatch;
} catch (_) {}
return null;
}
/// 正则提取 ic- 模式
String? _kr_extractCode(String source) {
final RegExp regExp = RegExp(r'ic-([A-Za-z0-9-_]+)');
final Match? match = regExp.firstMatch(source);
if (match != null && match.groupCount >= 1) {
final String code = match.group(1) ?? '';
KRLogUtil.kr_i('✅ [DEBUG] 成功匹配到邀请码: $code (源: $source)', tag: 'DeviceUtil');
return code;
}
return null;
}
/// (MacOS) 通过 hdiutil 查找挂载卷对应的原始 DMG 路径
Future<String?> _kr_getInviteCodeFromHdiutil(String execPath) async {
try {
final ProcessResult result = await Process.run('hdiutil', ['info']);
if (result.exitCode == 0) {
final String output = result.stdout.toString();
// 寻找每个条目包含的 image-path 和 实际挂载路径
final List<String> segments = output.split('================================================');
for (var segment in segments) {
if (!segment.contains('image-path')) continue;
String? imagePath;
String? mountPoint;
final List<String> lines = segment.split('\n');
for (var line in lines) {
final String trimmedLine = line.trim();
if (trimmedLine.isEmpty) continue;
if (trimmedLine.contains('image-path')) {
final int colonIndex = trimmedLine.indexOf(':');
if (colonIndex != -1) {
imagePath = trimmedLine.substring(colonIndex + 1).trim();
}
}
// 匹配类似: /dev/disk8s1 7C3457EF... /Volumes/HiFastVPN Installation
// 或者是正常的 mount-point 键值对
else if (trimmedLine.contains('/Volumes/')) {
if (trimmedLine.contains('mount-point')) {
final int colonIndex = trimmedLine.indexOf(':');
if (colonIndex != -1) {
mountPoint = trimmedLine.substring(colonIndex + 1).trim();
}
} else if (trimmedLine.startsWith('/dev/')) {
// 提取路径:通常在最后一个制表符或空格序列之后
final int volumesIndex = trimmedLine.indexOf('/Volumes/');
if (volumesIndex != -1) {
mountPoint = trimmedLine.substring(volumesIndex).trim();
}
}
}
}
if (imagePath != null && mountPoint != null) {
final normalizedMount = mountPoint.endsWith('/') ? mountPoint.substring(0, mountPoint.length - 1) : mountPoint;
if (execPath.startsWith(normalizedMount)) {
KRLogUtil.kr_i('🎯 [DEBUG] 成功匹配到挂载源: $imagePath', tag: 'DeviceUtil');
return _kr_extractCode(imagePath);
}
}
}
}
} catch (e) {
KRLogUtil.kr_e('❌ [DEBUG] hdiutil 追溯异常: $e', tag: 'DeviceUtil');
}
return null;
}
/// (MacOS) 通过 mdls 检查文件的来源下载链接
Future<String?> _kr_getInviteCodeFromMetadata(String execPath) async {
try {
// 提取 .app 的路径
final int appIndex = execPath.indexOf('.app');
final String targetPath = appIndex != -1
? execPath.substring(0, appIndex + 4)
: execPath;
final ProcessResult result = await Process.run('mdls', ['-name', 'kMDItemWhereFroms', targetPath]);
if (result.exitCode == 0) {
return _kr_extractCode(result.stdout.toString());
}
} catch (_) {}
return null;
}
}