diff --git a/android/app/build.gradle b/android/app/build.gradle index cdfe9ce..3f1ca4b 100755 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -62,7 +62,8 @@ android { versionName flutterVersionName multiDexEnabled true manifestPlaceholders = [ - 'android.permission.ACCESS_NETWORK_STATE': true + 'android.permission.ACCESS_NETWORK_STATE': true, + 'OPENINSTALL_APPKEY' : "alf57p", ] android.defaultConfig.manifestPlaceholders += [ 'android:screenOrientation': "portrait" diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 016ad70..400d4fc 100755 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -23,10 +23,6 @@ - - - - + + + + + + + + + + + + + + + + 2.8.2) - - Flutter - device_info_plus (0.0.1): - Flutter - EasyPermissionX/Camera (0.0.2) @@ -30,6 +24,10 @@ PODS: - in_app_purchase_storekit (0.0.1): - Flutter - FlutterMacOS + - libOpenInstallSDK (2.9.2) + - openinstall_flutter_plugin (0.0.1): + - Flutter + - libOpenInstallSDK - OrderedSet (6.0.3) - package_info_plus (0.4.5): - Flutter @@ -47,7 +45,6 @@ PODS: DEPENDENCIES: - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - - crisp_chat (from `.symlinks/plugins/crisp_chat/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - EasyPermissionX/Camera - Flutter (from `Flutter`) @@ -56,6 +53,7 @@ DEPENDENCIES: - flutter_udid (from `.symlinks/plugins/flutter_udid/ios`) - gal (from `.symlinks/plugins/gal/darwin`) - in_app_purchase_storekit (from `.symlinks/plugins/in_app_purchase_storekit/darwin`) + - openinstall_flutter_plugin (from `.symlinks/plugins/openinstall_flutter_plugin/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) @@ -64,8 +62,8 @@ DEPENDENCIES: SPEC REPOS: https://mirrors.tuna.tsinghua.edu.cn/git/CocoaPods/Specs.git: - - Crisp - EasyPermissionX + - libOpenInstallSDK - OrderedSet - ReachabilitySwift - SAMKeychain @@ -73,8 +71,6 @@ SPEC REPOS: EXTERNAL SOURCES: connectivity_plus: :path: ".symlinks/plugins/connectivity_plus/ios" - crisp_chat: - :path: ".symlinks/plugins/crisp_chat/ios" device_info_plus: :path: ".symlinks/plugins/device_info_plus/ios" Flutter: @@ -89,6 +85,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/gal/darwin" in_app_purchase_storekit: :path: ".symlinks/plugins/in_app_purchase_storekit/darwin" + openinstall_flutter_plugin: + :path: ".symlinks/plugins/openinstall_flutter_plugin/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" path_provider_foundation: @@ -102,8 +100,6 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d - Crisp: 6747c96b2b2c2a81babf1eaecd1688a65d98edd4 - crisp_chat: 30994104495de23443af8d5b2c041a6df1e8464d device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342 EasyPermissionX: ff4c438f6ee80488f873b4cb921e32d982523067 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 @@ -112,6 +108,8 @@ SPEC CHECKSUMS: flutter_udid: b2417673f287ee62817a1de3d1643f47b9f508ab gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5 in_app_purchase_storekit: a1ce04056e23eecc666b086040239da7619cd783 + libOpenInstallSDK: 1e123fde796902007e6a25797cdf34c20552fc6e + openinstall_flutter_plugin: e6b8486f834eb60b336546442a8b747d4b664cf4 OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index ea138b8..1198af1 100755 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -762,11 +762,9 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = PacketTunnel/HiddifyPacketTunnel.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=iphoneos*]" = NJRRF427XB; + DEVELOPMENT_TEAM = NJRRF427XB; ENABLE_USER_SCRIPT_SANDBOXING = YES; EXCLUDED_ARCHS = armv7; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -792,7 +790,6 @@ PRODUCT_BUNDLE_IDENTIFIER = "$(BASE_BUNDLE_IDENTIFIER).PacketTunnel"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "HiFastVPN-iOS-Pord-PacketTunnel"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -818,11 +815,9 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnelRelease.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=iphoneos*]" = NJRRF427XB; + DEVELOPMENT_TEAM = NJRRF427XB; ENABLE_USER_SCRIPT_SANDBOXING = YES; EXCLUDED_ARCHS = armv7; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -848,7 +843,6 @@ PRODUCT_BUNDLE_IDENTIFIER = "$(BASE_BUNDLE_IDENTIFIER).PacketTunnel"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "HiFastVPN-iOS-Pord-PacketTunnel"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -872,11 +866,9 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = PacketTunnel/HiddifyPacketTunnel.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=iphoneos*]" = NJRRF427XB; + DEVELOPMENT_TEAM = NJRRF427XB; ENABLE_USER_SCRIPT_SANDBOXING = YES; EXCLUDED_ARCHS = armv7; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -902,7 +894,6 @@ PRODUCT_BUNDLE_IDENTIFIER = "$(BASE_BUNDLE_IDENTIFIER).PacketTunnel"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "HiFastVPN-iOS-Pord-PacketTunnel"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -980,11 +971,9 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=iphoneos*]" = NJRRF427XB; + DEVELOPMENT_TEAM = NJRRF427XB; ENABLE_BITCODE = NO; "EXCLUDED_ARCHS[sdk=iphoneos*]" = armv7; INFOPLIST_FILE = Runner/Info.plist; @@ -1016,7 +1005,6 @@ "PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]" = com.taw.hifastvpn; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "HiFastVPN-iOS-Pord"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; @@ -1212,11 +1200,9 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=iphoneos*]" = NJRRF427XB; + DEVELOPMENT_TEAM = NJRRF427XB; ENABLE_BITCODE = NO; "EXCLUDED_ARCHS[sdk=iphoneos*]" = armv7; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "i386 arm64"; @@ -1248,7 +1234,6 @@ "PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]" = com.taw.hifastvpn; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "HiFastVPN-iOS-Pord"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; @@ -1270,11 +1255,9 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/RunnerRelease.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=iphoneos*]" = NJRRF427XB; + DEVELOPMENT_TEAM = NJRRF427XB; ENABLE_BITCODE = NO; "EXCLUDED_ARCHS[sdk=iphoneos*]" = armv7; INFOPLIST_FILE = Runner/Info.plist; @@ -1306,7 +1289,6 @@ "PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]" = com.taw.hifastvpn; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "HiFastVPN-iOS-Pord"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 9cd4b8c..0d936ad 100755 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -15,6 +15,14 @@ import Libcore return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + override func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { + return super.application(app, open: url, options: options) + } + + override func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { + return super.application(application, continue: userActivity, restorationHandler: restorationHandler) + } + func setupFileManager() { try? FileManager.default.createDirectory(at: FilePath.workingDirectory, withIntermediateDirectories: true) FileManager.default.changeCurrentDirectoryPath(FilePath.sharedDirectory.path) diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index aed9fe8..3ad8e58 100755 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -88,5 +88,7 @@ UIViewControllerBasedStatusBarAppearance + com.openinstall.APP_KEY + alf57p diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements index 53d974b..1d2cf40 100755 --- a/ios/Runner/Runner.entitlements +++ b/ios/Runner/Runner.entitlements @@ -4,6 +4,11 @@ aps-environment development + com.apple.developer.associated-domains + + applinks:alf57p.openinstall.com + applinks:alf57p.oplinking.com + com.apple.developer.networking.networkextension packet-tunnel-provider diff --git a/ios/Runner/RunnerRelease.entitlements b/ios/Runner/RunnerRelease.entitlements index 53d974b..1d2cf40 100755 --- a/ios/Runner/RunnerRelease.entitlements +++ b/ios/Runner/RunnerRelease.entitlements @@ -4,6 +4,11 @@ aps-environment development + com.apple.developer.associated-domains + + applinks:alf57p.openinstall.com + applinks:alf57p.oplinking.com + com.apple.developer.networking.networkextension packet-tunnel-provider diff --git a/ios/exportOptions_dev.plist b/ios/exportOptions_dev.plist new file mode 100755 index 0000000..af39c3a --- /dev/null +++ b/ios/exportOptions_dev.plist @@ -0,0 +1,12 @@ + + + + + compileBitcode + + method + development signingStyle + automatic thinning + <none> + + \ No newline at end of file diff --git a/lib/app/common/app_run_data.dart b/lib/app/common/app_run_data.dart index 6d1847e..2c096e9 100755 --- a/lib/app/common/app_run_data.dart +++ b/lib/app/common/app_run_data.dart @@ -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 _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 completer = Completer(); + 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 _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 kr_handleOpenInstallData(Map 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 data) { + try { + if (data.containsKey('bindData')) { + final bindDataStr = data['bindData'] as String?; + if (bindDataStr != null && bindDataStr.isNotEmpty) { + final Map bindData = jsonDecode(bindDataStr); + return bindData['inviteCode']?.toString(); + } + } + } catch (e) { + KRLogUtil.kr_e('解析 OpenInstall 数据中邀请码失败: $e', tag: 'AppRunData'); + } + return null; + } } diff --git a/lib/app/modules/kr_splash/controllers/kr_splash_controller.dart b/lib/app/modules/kr_splash/controllers/kr_splash_controller.dart index abc5f06..8bbd460 100755 --- a/lib/app/modules/kr_splash/controllers/kr_splash_controller.dart +++ b/lib/app/modules/kr_splash/controllers/kr_splash_controller.dart @@ -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'); diff --git a/lib/app/network/http_util.dart b/lib/app/network/http_util.dart index f96acb7..9ef6945 100755 --- a/lib/app/network/http_util.dart +++ b/lib/app/network/http_util.dart @@ -131,9 +131,11 @@ class HttpUtil { } } - /// request请求:T为转换的实体类, path:请求地址,query:请求参数, method: 请求方法, isShowLoading(可选): 是否显示加载中的状态,默认true显示, false为不显示 + /// request请求:T为转换的实体类, path:请求地址,query:请求参数, method: 请求方法, isShowLoading(可选): 是否显示加载中的状态,默认true显示, false为不显示, silentInvite: 是否为静默邀请 Future> request(String path, Map 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> responseTemp; if (method == HttpMethod.GET) { responseTemp = await _dio.get>( path, queryParameters: map, - options: Options( - contentType: "application/json", - headers: headers, // 添加请求头 - ), + options: options, ); } else if (method == HttpMethod.DELETE) { responseTemp = await _dio.delete>( path, data: map, - options: Options( - contentType: "application/json", - headers: headers, // 添加请求头 - ), + options: options, ); } else if (method == HttpMethod.PUT) { responseTemp = await _dio.put>( path, data: map, - options: Options( - contentType: "application/json", - headers: headers, // 添加请求头 - ), + options: options, ); } else { responseTemp = await _dio.post>( 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); diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index eb27e21..44c77b0 100755 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -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()), diff --git a/lib/app/services/api_service/kr_api.user.dart b/lib/app/services/api_service/kr_api.user.dart index ebabafc..52785ae 100755 --- a/lib/app/services/api_service/kr_api.user.dart +++ b/lib/app/services/api_service/kr_api.user.dart @@ -194,7 +194,7 @@ class KRUserApi { } /// 绑定样式 - Future> hi_inviteCode(String inviteCode) async { + Future> hi_inviteCode(String inviteCode, {bool isSilentInvite = false}) async { final Map data = {}; // 将字符串 ID 转换为整数 @@ -209,7 +209,8 @@ class KRUserApi { Api.hi_invite_code, data, method: HttpMethod.POST, - isShowLoading: true, + isShowLoading: !isSilentInvite, + isSilentInvite: isSilentInvite, ); if (!baseResponse.isSuccess) { diff --git a/lib/app/utils/kr_device_util.dart b/lib/app/utils/kr_device_util.dart index 6a83496..5bdade5 100755 --- a/lib/app/utils/kr_device_util.dart +++ b/lib/app/utils/kr_device_util.dart @@ -76,4 +76,168 @@ class KRDeviceUtil { _kr_cachedDeviceId = null; _kr_storage.kr_deleteData(key: _kr_deviceIdKey); } -} \ No newline at end of file + + /// 从桌面平台提取邀请码 (深度检测) + /// 1. 检测执行路径 (支持重命名的 .app 或二进制) + /// 2. (MacOS) 检测挂载源 (支持重命名的 DMG) + /// 3. (MacOS) 检测来源元数据 (支持从下载链接中识别) + Future 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 _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 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 _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 segments = output.split('================================================'); + for (var segment in segments) { + if (!segment.contains('image-path')) continue; + + String? imagePath; + String? mountPoint; + + final List 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 _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; + } +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index b2a761a..4b7655a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1040,6 +1040,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.0" + openinstall_flutter_plugin: + dependency: "direct main" + description: + name: openinstall_flutter_plugin + sha256: f711857513a546eb18590c7869fec8cb707c7d815737a91d07420e6838f8fbf5 + url: "https://pub.dev" + source: hosted + version: "2.5.7" package_config: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 80c9c22..34713dc 100755 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -112,6 +112,7 @@ dependencies: in_app_purchase_storekit: ^0.4.2 intl: ^0.20.2 gal: ^2.3.2 + openinstall_flutter_plugin: ^2.5.7 dev_dependencies: flutter_test: sdk: flutter diff --git a/scripts/build_dmg.sh b/scripts/build_dmg.sh index fe9b0b1..5b1d9be 100755 --- a/scripts/build_dmg.sh +++ b/scripts/build_dmg.sh @@ -14,7 +14,7 @@ set -e SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" -APP_PATH="${PROJECT_DIR}/build/macos/Build/Products/Release/HiFastVPN.app" +APP_PATH="${PROJECT_DIR}/build/macos/Build/Products/Debug/HiFastVPN.app" BACKGROUND_IMAGE="${SCRIPT_DIR}/assets/dmg_bg.png" # ======================== 提取版本号 ======================== diff --git a/邀请文档.md b/邀请文档.md new file mode 100644 index 0000000..1fd9a60 --- /dev/null +++ b/邀请文档.md @@ -0,0 +1,36 @@ +## 文档更新内容补充 +- 收录 OpenInstall 配置参数: + - appkey:alf57p + - iOS Associated Domains:applinks:alf57p.oplinking.com +- 其余方案保持既定约束:静默邀请、仅在 kr_deviceLogin 成功后调用、仅打印日志、不改动 UI、不清理本地、桌面平台从包名截取邀请码、网络层 silentInvite 标识屏蔽响应弹窗但保留请求拦截。 + +## 《说明文档.md》新增/修订片段(拟写) + +### OpenInstall 配置 +- 版本:openinstall_flutter_plugin:^2.5.7 +- appkey:alf57p +- iOS: + - Associated Domains:applinks:alf57p.oplinking.com + - 在 AppDelegate 转发 openURL/Universal Links 到插件 +- Android: + - Manifest 配置深链与自定义 scheme(与 iOS 域名对应),按官方文档完成 + +### 触发点与静默行为 +- 触发点:await authApi.kr_deviceLogin() 成功分支([app_run_data.dart](file:///Users/apple/Documents/source/hi-client/lib/app/common/app_run_data.dart#L410-L460)),在 kr_saveUserInfo 之前调用 KRUserApi().hi_inviteCode(inviteCode) +- 静默:仅打印控制台日志,不走响应弹窗,不清理本地状态,不重试 + +### 桌面平台(Windows/macOS) +- 从可执行/包名解析邀请码:命名规范 ic-,正则 /ic-([A-Za-z0-9-_]+)/ + +### 网络层标识(silentInvite) +- 载体:Dio RequestOptions.extra(可选 Header: X-Client-Mode: invite_silent) +- 行为:保留请求拦截;屏蔽响应弹窗与登录失效弹窗;错误向上传递,调用方仅打印日志 + +### 测试清单 +- iOS UL 验证:applinks:alf57p.oplinking.com +- Android 深链验证 +- 设备登录成功后静默绑定日志验证 +- 桌面解析包名验证 + +## 交付 +- 经你确认后,我将把上述内容写入《说明文档.md》,目前仍不进行代码改动。 \ No newline at end of file