diff --git a/client/android/app/src/main/AndroidManifest.xml b/client/android/app/src/main/AndroidManifest.xml index f7f5fc3..86c30f5 100644 --- a/client/android/app/src/main/AndroidManifest.xml +++ b/client/android/app/src/main/AndroidManifest.xml @@ -1,9 +1,17 @@ + + + + + + + + + android:resource="@style/NormalTheme" /> + + + + + + + + diff --git a/client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/MainActivity.kt b/client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/MainActivity.kt index a92fcfb..5a9c62b 100644 --- a/client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/MainActivity.kt +++ b/client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/MainActivity.kt @@ -1,5 +1,122 @@ package com.pangolin.pangolin_vpn +import android.util.Log import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodChannel -class MainActivity: FlutterActivity() +/** + * MainActivity — Flutter ↔ 原生通道注册 + * + * 通道契约(见 lib/bridge/vpn_bridge.dart 头部注释): + * MethodChannel : pangolin/vpn + * EventChannel : pangolin/vpn/status + * EventChannel : pangolin/vpn/stats + * + * MethodChannel 路由到 PangolinVpnService(通过 Intent)。 + * EventChannel 由 VpnEventBus 驱动(Service → EventBus → EventSink → Flutter)。 + */ +class MainActivity : FlutterActivity() { + + companion object { + private const val TAG = "PangolinMainActivity" + private const val VPN_CHANNEL = "pangolin/vpn" + private const val STATUS_CHANNEL = "pangolin/vpn/status" + private const val STATS_CHANNEL = "pangolin/vpn/stats" + } + + // 持有 EventSink 引用,以便 VpnEventBus 回调时转发 + private var statusEventSink: EventChannel.EventSink? = null + private var statsEventSink: EventChannel.EventSink? = null + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + val messenger = flutterEngine.dartExecutor.binaryMessenger + + // ── MethodChannel ──────────────────────────────────────── + MethodChannel(messenger, VPN_CHANNEL).setMethodCallHandler { call, result -> + Log.d(TAG, "MethodChannel: ${call.method}") + when (call.method) { + "start" -> { + val configJson = call.arguments as? String ?: "{}" + PangolinVpnService.startVpn(this, configJson) + result.success(null) + } + "stop" -> { + PangolinVpnService.stopVpn(this) + result.success(null) + } + "getStatus" -> { + // TODO(11E): 从 VpnService 查询真实状态 + result.success("off") + } + "selectOutbound" -> { + val tag = call.arguments as? String ?: "auto" + Log.d(TAG, "selectOutbound: tag=$tag") + // TODO(11E): 发送 IPC 到 VpnService → sing-box outbound selector + result.success(null) + } + "getActiveOutbound" -> { + // TODO(11E): 从 VpnService 查询当前出口 tag + result.success("auto") + } + "setKillSwitch" -> { + val on = call.arguments as? Boolean ?: false + Log.d(TAG, "setKillSwitch: on=$on") + // TODO(11E): 配置 VpnService allowedApplications / blockingMode + result.success(null) + } + else -> result.notImplemented() + } + } + + // ── EventChannel: status ───────────────────────────────── + EventChannel(messenger, STATUS_CHANNEL).setStreamHandler( + object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink) { + Log.d(TAG, "status channel: onListen") + statusEventSink = events + VpnEventBus.setStatusListener(object : VpnEventBus.StatusListener { + override fun onStatus(status: String) { + statusEventSink?.success(status) + } + }) + } + override fun onCancel(arguments: Any?) { + Log.d(TAG, "status channel: onCancel") + statusEventSink = null + VpnEventBus.setStatusListener(null) + } + } + ) + + // ── EventChannel: stats ────────────────────────────────── + EventChannel(messenger, STATS_CHANNEL).setStreamHandler( + object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink) { + Log.d(TAG, "stats channel: onListen") + statsEventSink = events + VpnEventBus.setStatsListener(object : VpnEventBus.StatsListener { + override fun onStats(stats: Map) { + statsEventSink?.success(stats) + } + }) + } + override fun onCancel(arguments: Any?) { + Log.d(TAG, "stats channel: onCancel") + statsEventSink = null + VpnEventBus.setStatsListener(null) + } + } + ) + } + + override fun onDestroy() { + statusEventSink = null + statsEventSink = null + VpnEventBus.setStatusListener(null) + VpnEventBus.setStatsListener(null) + super.onDestroy() + } +} diff --git a/client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/PangolinVpnService.kt b/client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/PangolinVpnService.kt new file mode 100644 index 0000000..c82b64d --- /dev/null +++ b/client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/PangolinVpnService.kt @@ -0,0 +1,157 @@ +package com.pangolin.pangolin_vpn + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.net.VpnService +import android.os.Build +import android.util.Log + +/** + * PangolinVpnService — VPN 服务骨架(空壳) + * + * 生命周期占位日志 + 前台通知;实际隧道建立由 11E 完成。 + * + * 通信: + * - [MainActivity] 通过 [startVpn] / [stopVpn] 发送 Intent 启动/停止本服务 + * - 状态回调通过 [VpnEventBus] (Application 级 LiveData) 传到 MainActivity EventSink + * + * TODO(11E): 在 onStartCommand 中初始化 libbox / sing-box TUN 隧道。 + */ +class PangolinVpnService : VpnService() { + + companion object { + private const val TAG = "PangolinVpnService" + + const val ACTION_START = "com.pangolin.vpn.START" + const val ACTION_STOP = "com.pangolin.vpn.STOP" + const val EXTRA_CONFIG = "config_json" + + private const val NOTIFICATION_CHANNEL_ID = "pangolin_vpn_channel" + private const val NOTIFICATION_ID = 1001 + + fun startVpn(context: Context, configJson: String) { + val intent = Intent(context, PangolinVpnService::class.java).apply { + action = ACTION_START + putExtra(EXTRA_CONFIG, configJson) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + Log.d(TAG, "startVpn: intent dispatched") + } + + fun stopVpn(context: Context) { + val intent = Intent(context, PangolinVpnService::class.java).apply { + action = ACTION_STOP + } + context.startService(intent) + Log.d(TAG, "stopVpn: intent dispatched") + } + } + + // ── 生命周期 ──────────────────────────────────────────────── + + override fun onCreate() { + super.onCreate() + Log.i(TAG, "onCreate") + createNotificationChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Log.i(TAG, "onStartCommand: action=${intent?.action}") + return when (intent?.action) { + ACTION_START -> { + val configJson = intent.getStringExtra(EXTRA_CONFIG) ?: "{}" + handleStart(configJson) + START_STICKY + } + ACTION_STOP -> { + handleStop() + START_NOT_STICKY + } + else -> { + Log.w(TAG, "onStartCommand: unknown action=${intent?.action}") + START_NOT_STICKY + } + } + } + + override fun onDestroy() { + Log.i(TAG, "onDestroy") + handleStop() + super.onDestroy() + } + + override fun onRevoke() { + Log.w(TAG, "onRevoke: VPN permission revoked by system") + // TODO(11E): 通知 Dart 侧 status=error + handleStop() + } + + // ── 内部处理 ──────────────────────────────────────────────── + + private fun handleStart(configJson: String) { + Log.i(TAG, "handleStart: config=${configJson.take(80)}…") + startForeground(NOTIFICATION_ID, buildNotification("正在连接…")) + // TODO(11E): 解析 configJson → 构建 VPN 接口 → 启动 sing-box 内核 + // 状态变化后通过 VpnEventBus.postStatus("connecting") / ("on") 通知 MainActivity + } + + private fun handleStop() { + Log.i(TAG, "handleStop") + stopForeground(true) + stopSelf() + // TODO(11E): 停止 sing-box 内核 → 关闭 TUN fd + } + + // ── 前台通知 ──────────────────────────────────────────────── + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + NOTIFICATION_CHANNEL_ID, + "穿山甲 VPN", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "穿山甲 VPN 隧道状态" + setShowBadge(false) + } + val nm = getSystemService(NotificationManager::class.java) + nm.createNotificationChannel(channel) + } + } + + private fun buildNotification(contentText: String): Notification { + val pendingFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + val openIntent = PendingIntent.getActivity( + this, 0, + Intent(this, MainActivity::class.java), + pendingFlags + ) + + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(this, NOTIFICATION_CHANNEL_ID) + } else { + @Suppress("DEPRECATION") + Notification.Builder(this) + } + + return builder + .setContentTitle("穿山甲") + .setContentText(contentText) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentIntent(openIntent) + .setOngoing(true) + .build() + } +} diff --git a/client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/VpnEventBus.kt b/client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/VpnEventBus.kt new file mode 100644 index 0000000..6d0eea4 --- /dev/null +++ b/client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/VpnEventBus.kt @@ -0,0 +1,42 @@ +package com.pangolin.pangolin_vpn + +import android.os.Handler +import android.os.Looper + +/** + * VpnEventBus — Application 级状态总线 + * + * 解耦 PangolinVpnService 与 MainActivity: + * - Service 调用 postStatus / postStats 推送事件 + * - MainActivity 注册 StatusListener / StatsListener 接收事件并转发给 Flutter EventSink + * + * 线程安全:内部切换到主线程再回调(VPN 内核可能在工作线程回调)。 + */ +object VpnEventBus { + + interface StatusListener { + fun onStatus(status: String) + } + + interface StatsListener { + fun onStats(stats: Map) + } + + private val mainHandler = Handler(Looper.getMainLooper()) + + @Volatile private var statusListener: StatusListener? = null + @Volatile private var statsListener: StatsListener? = null + + fun setStatusListener(l: StatusListener?) { statusListener = l } + fun setStatsListener(l: StatsListener?) { statsListener = l } + + /** 由 PangolinVpnService(可能在工作线程)调用 */ + fun postStatus(status: String) { + mainHandler.post { statusListener?.onStatus(status) } + } + + /** 由 PangolinVpnService(可能在工作线程)调用 */ + fun postStats(stats: Map) { + mainHandler.post { statsListener?.onStats(stats) } + } +} diff --git a/client/ios/PacketTunnel/Info.plist b/client/ios/PacketTunnel/Info.plist new file mode 100644 index 0000000..68c0a5b --- /dev/null +++ b/client/ios/PacketTunnel/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDisplayName + 穿山甲 Tunnel + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + PacketTunnel + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + NSExtension + + NSExtensionPointIdentifier + com.apple.networkextension.packet-tunnel + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).PacketTunnelProvider + + + diff --git a/client/ios/PacketTunnel/PacketTunnelProvider.swift b/client/ios/PacketTunnel/PacketTunnelProvider.swift new file mode 100644 index 0000000..9a46fa0 --- /dev/null +++ b/client/ios/PacketTunnel/PacketTunnelProvider.swift @@ -0,0 +1,78 @@ +// PacketTunnelProvider.swift — Packet Tunnel Extension 骨架 +// +// Target: PacketTunnel (NEPacketTunnelProvider extension) +// Bundle: com.pangolin.pangolinVpn.PacketTunnel +// +// 生命周期占位日志;实际 TUN 隧道建立与 libbox 接线由 11F 完成。 +// +// 与主 App 通信: +// 主 App → extension: NETunnelProviderSession.sendProviderMessage(_:responseHandler:) +// extension → 主 App: 通过 NEProvider.cancelTunnelWithError 或 NETunnelProviderSession 回调 +// +// TODO(11F): 在 startTunnel 中初始化 sing-box/libbox TUN 模式内核。 + +import NetworkExtension +import os.log + +class PacketTunnelProvider: NEPacketTunnelProvider { + + private let log = OSLog( + subsystem: "com.pangolin.pangolinVpn.PacketTunnel", + category: "VPN" + ) + + // ── NEPacketTunnelProvider 生命周期 ────────────────────────── + + override func startTunnel( + options: [String: NSObject]?, + completionHandler: @escaping (Error?) -> Void + ) { + os_log( + "PacketTunnelProvider: startTunnel — options keys: %{public}@", + log: log, type: .info, + (options?.keys.joined(separator: ", ") ?? "(none)") + ) + // TODO(11F): 解析 options["config"] → 写入临时配置文件 + // → 调用 libbox/sing-box TUN 启动 API + // → 配置 packetFlow / setTunnelNetworkSettings + completionHandler(nil) + } + + override func stopTunnel( + with reason: NEProviderStopReason, + completionHandler: @escaping () -> Void + ) { + os_log( + "PacketTunnelProvider: stopTunnel — reason=%d", + log: log, type: .info, + reason.rawValue + ) + // TODO(11F): 停止 sing-box 内核 → 关闭 TUN fd + completionHandler() + } + + override func handleAppMessage( + _ messageData: Data, + completionHandler: ((Data?) -> Void)? + ) { + os_log( + "PacketTunnelProvider: handleAppMessage — %d bytes", + log: log, type: .debug, + messageData.count + ) + // TODO(11F): 解码 messageData(JSON 或 protobuf)→ 路由到内核命令 + // 如 selectOutbound / getStats 等 + completionHandler?(nil) + } + + override func sleep(completionHandler: @escaping () -> Void) { + os_log("PacketTunnelProvider: sleep", log: log, type: .debug) + // TODO(11F): 挂起内核流量处理 + completionHandler() + } + + override func wake() { + os_log("PacketTunnelProvider: wake", log: log, type: .debug) + // TODO(11F): 恢复内核流量处理 + } +} diff --git a/client/ios/Runner.xcodeproj/project.pbxproj b/client/ios/Runner.xcodeproj/project.pbxproj index 6441d4b..dd620b3 100644 --- a/client/ios/Runner.xcodeproj/project.pbxproj +++ b/client/ios/Runner.xcodeproj/project.pbxproj @@ -13,8 +13,26 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + /* VpnManager.swift in Runner Sources */ + CAFE000000000001000000AB /* VpnManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE000000000001000000AA /* VpnManager.swift */; }; + /* PacketTunnelProvider.swift in PacketTunnel Sources */ + CAFE000000000002000000AB /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE000000000002000000AA /* PacketTunnelProvider.swift */; }; + /* PacketTunnel Info.plist in PacketTunnel Resources */ + CAFE000000000003000000AB /* Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = CAFE000000000003000000AA /* Info.plist */; }; + /* PacketTunnel.appex embedded in Runner */ + CAFE000000000004000000AB /* PacketTunnel.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = CAFE000000000004000000AA /* PacketTunnel.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + CAFE000000000070000000AA /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = CAFE000000000030000000AA /* PacketTunnel */; + remoteInfo = PacketTunnel; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXCopyFilesBuildPhase section */ 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; @@ -26,6 +44,17 @@ name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; + CAFE000000000060000000AA /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + CAFE000000000004000000AB /* PacketTunnel.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -41,6 +70,14 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 1771E259F7494EBC8BB1F97A /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + /* VpnManager.swift (Runner) */ + CAFE000000000001000000AA /* VpnManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VpnManager.swift; sourceTree = ""; }; + /* PacketTunnelProvider.swift (PacketTunnel extension) */ + CAFE000000000002000000AA /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = ""; }; + /* PacketTunnel/Info.plist */ + CAFE000000000003000000AA /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + /* PacketTunnel.appex product */ + CAFE000000000004000000AA /* PacketTunnel.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = PacketTunnel.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -51,6 +88,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + CAFE000000000010000000AC /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -70,6 +114,7 @@ children = ( 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, + CAFE000000000020000000AA /* PacketTunnel */, 97C146EF1CF9000F007C117D /* Products */, ); sourceTree = ""; @@ -78,6 +123,7 @@ isa = PBXGroup; children = ( 97C146EE1CF9000F007C117D /* Runner.app */, + CAFE000000000004000000AA /* PacketTunnel.appex */, ); name = Products; sourceTree = ""; @@ -90,12 +136,22 @@ 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 97C146F11CF9000F007C117D /* Info.plist */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + CAFE000000000001000000AA /* VpnManager.swift */, 1498D2311E8E86230040F4C2 /* GeneratedPluginRegistrant.m */, 1771E259F7494EBC8BB1F97A /* GeneratedPluginRegistrant.h */, ); path = Runner; sourceTree = ""; }; + CAFE000000000020000000AA /* PacketTunnel */ = { + isa = PBXGroup; + children = ( + CAFE000000000002000000AA /* PacketTunnelProvider.swift */, + CAFE000000000003000000AA /* Info.plist */, + ); + path = PacketTunnel; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -109,16 +165,35 @@ 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06F39B1EFE8EE800ECD283 /* Thin Binary */, + CAFE000000000060000000AA /* Embed App Extensions */, ); buildRules = ( ); dependencies = ( + CAFE000000000070000000AB /* PBXTargetDependency */, ); name = Runner; productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; }; + CAFE000000000030000000AA /* PacketTunnel */ = { + isa = PBXNativeTarget; + buildConfigurationList = CAFE000000000050000000AA /* Build configuration list for PBXNativeTarget "PacketTunnel" */; + buildPhases = ( + CAFE000000000010000000AA /* Sources */, + CAFE000000000010000000AC /* Frameworks */, + CAFE000000000010000000AB /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = PacketTunnel; + productName = PacketTunnel; + productReference = CAFE000000000004000000AA /* PacketTunnel.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -132,6 +207,9 @@ CreatedOnToolsVersion = 7.3.1; LastSwiftMigration = 0900; }; + CAFE000000000030000000AA = { + CreatedOnToolsVersion = 1510; + }; }; }; buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; @@ -148,6 +226,7 @@ projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, + CAFE000000000030000000AA /* PacketTunnel */, ); }; /* End PBXProject section */ @@ -164,6 +243,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + CAFE000000000010000000AB /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + CAFE000000000003000000AB /* Info.plist in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -206,12 +293,29 @@ buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + CAFE000000000001000000AB /* VpnManager.swift in Sources */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; + CAFE000000000010000000AA /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + CAFE000000000002000000AB /* PacketTunnelProvider.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + CAFE000000000070000000AB /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = CAFE000000000030000000AA /* PacketTunnel */; + targetProxy = CAFE000000000070000000AA /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 249021D3217E4FDB00AE95B9 /* Profile */ = { isa = XCBuildConfiguration; @@ -435,6 +539,75 @@ }; name = Release; }; + /* PacketTunnel build configurations */ + CAFE000000000040000000AA /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + INFOPLIST_FILE = PacketTunnel/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.pangolin.pangolinVpn.PacketTunnel"; + PRODUCT_NAME = PacketTunnel; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + CAFE000000000040000000AB /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + INFOPLIST_FILE = PacketTunnel/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.pangolin.pangolinVpn.PacketTunnel"; + PRODUCT_NAME = PacketTunnel; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + CAFE000000000040000000AC /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + INFOPLIST_FILE = PacketTunnel/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.pangolin.pangolinVpn.PacketTunnel"; + PRODUCT_NAME = PacketTunnel; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Profile; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -458,6 +631,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + CAFE000000000050000000AA /* Build configuration list for PBXNativeTarget "PacketTunnel" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CAFE000000000040000000AA /* Debug */, + CAFE000000000040000000AB /* Release */, + CAFE000000000040000000AC /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; diff --git a/client/ios/Runner/AppDelegate.swift b/client/ios/Runner/AppDelegate.swift index 70693e4..5da39a2 100644 --- a/client/ios/Runner/AppDelegate.swift +++ b/client/ios/Runner/AppDelegate.swift @@ -1,13 +1,98 @@ +// AppDelegate.swift — Flutter ↔ 原生通道注册 +// +// 通道契约(见 lib/bridge/vpn_bridge.dart 头部注释): +// MethodChannel : pangolin/vpn +// EventChannel : pangolin/vpn/status → VpnStatusStreamHandler +// EventChannel : pangolin/vpn/stats → VpnStatsStreamHandler +// +// VpnManager.shared 持有 NETunnelProviderManager 与 NEVPNStatus 订阅。 + import UIKit import Flutter +import NetworkExtension @UIApplicationMain @objc class AppDelegate: FlutterAppDelegate { - override func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? - ) -> Bool { - GeneratedPluginRegistrant.register(with: self) - return super.application(application, didFinishLaunchingWithOptions: launchOptions) - } + + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + + guard let controller = window?.rootViewController as? FlutterViewController else { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + let messenger = controller.binaryMessenger + + // ── MethodChannel ──────────────────────────────────────── + let vpnChannel = FlutterMethodChannel(name: "pangolin/vpn", binaryMessenger: messenger) + vpnChannel.setMethodCallHandler { [weak self] call, result in + self?.handleVpnMethod(call: call, result: result) + } + + // ── EventChannel: status ───────────────────────────────── + FlutterEventChannel(name: "pangolin/vpn/status", binaryMessenger: messenger) + .setStreamHandler(VpnStatusStreamHandler.shared) + + // ── EventChannel: stats ────────────────────────────────── + FlutterEventChannel(name: "pangolin/vpn/stats", binaryMessenger: messenger) + .setStreamHandler(VpnStatsStreamHandler.shared) + + // 预加载 VPN 偏好设置(首次为空,静默失败) + VpnManager.shared.loadFromPreferences { error in + if let error = error { + NSLog("VpnManager: loadFromPreferences error: %@", error.localizedDescription) + } + } + + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + // ── MethodChannel handler ───────────────────────────────────── + + private func handleVpnMethod(call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + + case "start": + let configJson = call.arguments as? String ?? "{}" + VpnManager.shared.start(configJson: configJson) { error in + if let error = error { + result(FlutterError( + code: "START_FAILED", + message: error.localizedDescription, + details: nil + )) + } else { + result(nil) + } + } + + case "stop": + VpnManager.shared.stop() + result(nil) + + case "getStatus": + result(VpnManager.shared.statusString) + + case "selectOutbound": + let tag = call.arguments as? String ?? "auto" + NSLog("VPN: selectOutbound tag=%@", tag) + // TODO(11F): sendProviderMessage → PacketTunnelProvider outbound selector + result(nil) + + case "getActiveOutbound": + // TODO(11F): query from PacketTunnelProvider via sendProviderMessage + result("auto") + + case "setKillSwitch": + let on = call.arguments as? Bool ?? false + NSLog("VPN: setKillSwitch on=%d", on ? 1 : 0) + // TODO(11F): 通过 include/excludeRoutes 实现 Kill Switch + result(nil) + + default: + result(FlutterMethodNotImplemented) + } + } } diff --git a/client/ios/Runner/Info.plist b/client/ios/Runner/Info.plist index 19349c8..d665060 100644 --- a/client/ios/Runner/Info.plist +++ b/client/ios/Runner/Info.plist @@ -47,5 +47,8 @@ UIApplicationSupportsIndirectInputEvents + + NSVPNUsageDescription + 穿山甲使用 VPN 为您的网络连接提供加密保护。 diff --git a/client/ios/Runner/VpnManager.swift b/client/ios/Runner/VpnManager.swift new file mode 100644 index 0000000..447e93a --- /dev/null +++ b/client/ios/Runner/VpnManager.swift @@ -0,0 +1,187 @@ +// VpnManager.swift — 主 App 侧 NETunnelProviderManager 封装 +// +// 职责: +// 1. loadAllFromPreferences / saveToPreferences — 首次安装时建立 VPN 配置条目 +// 2. start / stop — 通过 NETunnelProviderSession 驱动 PacketTunnel extension +// 3. sendProviderMessage — 透传 IPC 消息到 PacketTunnelProvider.handleAppMessage +// +// AppDelegate 持有本类单例,在 MethodChannel handler 中调用其方法。 +// +// TODO(11F): 实现 start/stop 前的证书/权限检查;订阅 NEVPNStatus 通知并 +// 通过 VpnStatusStreamHandler.shared.eventSink 推送到 Dart 侧。 + +import Foundation +import NetworkExtension + +// ── 流处理器(Flutter EventChannel stream handlers)─────────────── + +/// status 流:推送 "off" / "connecting" / "on" / "error" 字符串 +class VpnStatusStreamHandler: NSObject, FlutterStreamHandler { + static let shared = VpnStatusStreamHandler() + private(set) var eventSink: FlutterEventSink? + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + eventSink = events + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + eventSink = nil + return nil + } + + func push(_ status: String) { + DispatchQueue.main.async { self.eventSink?(status) } + } +} + +/// stats 流:推送 [String: Any] 字典,字段同 vpn_bridge.dart VpnStatsEvent schema +class VpnStatsStreamHandler: NSObject, FlutterStreamHandler { + static let shared = VpnStatsStreamHandler() + private(set) var eventSink: FlutterEventSink? + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + eventSink = events + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + eventSink = nil + return nil + } + + func push(_ stats: [String: Any]) { + DispatchQueue.main.async { self.eventSink?(stats) } + } +} + +// ── VPN 管理器 ──────────────────────────────────────────────────── + +class VpnManager: NSObject { + + static let shared = VpnManager() + + private var tunnelManager: NETunnelProviderManager? + + // Bundle ID 与 PacketTunnel target PRODUCT_BUNDLE_IDENTIFIER 保持同步 + private let extensionBundleId = "com.pangolin.pangolinVpn.PacketTunnel" + + private var statusObserver: NSObjectProtocol? + + private override init() { + super.init() + subscribeToVpnStatus() + } + + // ── 偏好设置加载与保存 ───────────────────────────────────── + + /// 从系统偏好加载已存在的 VPN 配置(首次为空列表)。 + /// 必须在调用 start() 前完成。 + func loadFromPreferences(completion: @escaping (Error?) -> Void) { + NETunnelProviderManager.loadAllFromPreferences { [weak self] managers, error in + guard let self = self else { return } + if let error = error { + completion(error) + return + } + self.tunnelManager = managers?.first(where: { + ($0.protocolConfiguration as? NETunnelProviderProtocol)? + .providerBundleIdentifier == self.extensionBundleId + }) ?? NETunnelProviderManager() + completion(nil) + } + } + + /// 创建或更新 VPN 配置条目(首次安装时调用一次)。 + func saveConfiguration(serverAddress: String = "pangolin", completion: @escaping (Error?) -> Void) { + let manager = tunnelManager ?? NETunnelProviderManager() + let proto = NETunnelProviderProtocol() + proto.providerBundleIdentifier = extensionBundleId + proto.serverAddress = serverAddress + manager.protocolConfiguration = proto + manager.localizedDescription = "穿山甲 Pangolin VPN" + manager.isEnabled = true + manager.saveToPreferences { [weak self] error in + if error == nil { self?.tunnelManager = manager } + completion(error) + } + } + + // ── 隧道控制 ────────────────────────────────────────────── + + /// 启动 VPN 隧道(通过 NETunnelProviderSession)。 + /// [configJson]: sing-box 配置 JSON,作为 options 传入 PacketTunnelProvider.startTunnel。 + func start(configJson: String, completion: @escaping (Error?) -> Void) { + guard let manager = tunnelManager else { + completion(makeError("VpnManager: tunnelManager not loaded. Call loadFromPreferences first.")) + return + } + do { + let options: [String: NSObject] = ["config": configJson as NSString] + try manager.connection.startVPNTunnel(options: options) + completion(nil) + } catch { + completion(error) + } + } + + /// 停止 VPN 隧道。 + func stop() { + tunnelManager?.connection.stopVPNTunnel() + } + + /// 当前连接状态字符串(供 getStatus MethodChannel 方法使用)。 + var statusString: String { + switch tunnelManager?.connection.status ?? .invalid { + case .invalid, .disconnected: return "off" + case .connecting: return "connecting" + case .connected: return "on" + case .disconnecting: return "connecting" + case .reasserting: return "connecting" + @unknown default: return "error" + } + } + + // ── IPC 消息通道 ────────────────────────────────────────── + + /// 向 PacketTunnelProvider.handleAppMessage 发送消息。 + func sendProviderMessage(_ data: Data, responseHandler: ((Data?, Error?) -> Void)?) { + guard let session = tunnelManager?.connection as? NETunnelProviderSession else { + responseHandler?(nil, makeError("VpnManager: no active NETunnelProviderSession")) + return + } + do { + try session.sendProviderMessage(data) { responseData in + responseHandler?(responseData, nil) + } + } catch { + responseHandler?(nil, error) + } + } + + // ── NEVPNStatus 订阅 ────────────────────────────────────── + + private func subscribeToVpnStatus() { + statusObserver = NotificationCenter.default.addObserver( + forName: NSNotification.Name.NEVPNStatusDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + guard let self = self else { return } + let s = self.statusString + VpnStatusStreamHandler.shared.push(s) + } + } + + deinit { + if let obs = statusObserver { + NotificationCenter.default.removeObserver(obs) + } + } + + // ── 工具 ───────────────────────────────────────────────── + + private func makeError(_ message: String) -> NSError { + NSError(domain: "VpnManager", code: -1, userInfo: [NSLocalizedDescriptionKey: message]) + } +} diff --git a/client/lib/bridge/kernel_process.dart b/client/lib/bridge/kernel_process.dart new file mode 100644 index 0000000..dddf11d --- /dev/null +++ b/client/lib/bridge/kernel_process.dart @@ -0,0 +1,112 @@ +// kernel_process.dart — 桌面端内核子进程管理接口 +// +// 桌面平台(macOS / Windows / Linux)直接将 sing-box 二进制作为 +// 子进程运行,通过其 Clash 兼容 REST API 进行控制。 +// +// 本文件仅定义接口契约;具体实现由任务 11D(桌面 PoC)完成。 +// +// 进程生命周期: +// spawn(configPath) → 子进程启动,REST API 开始监听 +// kill() → 优雅停止(SIGTERM),超时后 SIGKILL +// +// Clash API 通信由 [ClashApiClient] 封装,基地址固定为 +// http://127.0.0.1:9090(可覆盖)。 + +import 'dart:async'; + +// ── Clash API 客户端 ───────────────────────────────────────────── + +/// sing-box Clash 兼容 REST API 客户端。 +/// 实现由 11D(桌面 PoC)填充;本骨架仅定义调用约定。 +class ClashApiClient { + ClashApiClient({this.baseUrl = 'http://127.0.0.1:9090'}); + + final String baseUrl; + + /// GET /traffic — 流量统计 + Future> getTraffic() { + throw UnimplementedError('ClashApiClient.getTraffic — 11D 实现'); + } + + /// GET /proxies — 节点列表 + Future> getProxies() { + throw UnimplementedError('ClashApiClient.getProxies — 11D 实现'); + } + + /// PUT /proxies/{group} — 切换出口 + Future selectProxy(String group, String proxy) { + throw UnimplementedError('ClashApiClient.selectProxy — 11D 实现'); + } + + /// GET /connections — 实时连接 + Future> getConnections() { + throw UnimplementedError('ClashApiClient.getConnections — 11D 实现'); + } + + /// DELETE /connections — 关闭所有连接 + Future closeAllConnections() { + throw UnimplementedError('ClashApiClient.closeAllConnections — 11D 实现'); + } +} + +// ── 进程管理接口 ───────────────────────────────────────────────── + +/// 桌面端内核子进程管理接口。 +/// +/// 使用方式: +/// ```dart +/// final kernel = DesktopKernelProcess(); +/// await kernel.spawn('/path/to/config.json'); +/// final traffic = await kernel.clashApiClient.getTraffic(); +/// await kernel.kill(); +/// ``` +abstract class KernelProcess { + /// 启动内核子进程。 + /// + /// [configPath] 为 sing-box JSON 配置文件的绝对路径。 + /// 返回的 [Future] 在进程就绪(REST API 可用)后 complete。 + Future spawn(String configPath); + + /// 停止内核子进程。 + /// + /// 先发 SIGTERM,[gracePeriod] 内未退出则 SIGKILL。 + Future kill({ + Duration gracePeriod = const Duration(seconds: 3), + }); + + /// 进程是否正在运行。 + bool get isRunning; + + /// Clash 兼容 REST API 客户端(进程运行时可用)。 + ClashApiClient get clashApiClient; + + /// 进程标准输出/标准错误日志流(用于调试面板)。 + Stream get logStream; +} + +// ── 占位实现(防 analyze 报错)──────────────────────────────────── + +/// 占位实现,抛出 [UnimplementedError]。 +/// 11D 替换为真实子进程实现时删除本类。 +class DesktopKernelProcess implements KernelProcess { + @override + Future spawn(String configPath) { + throw UnimplementedError('DesktopKernelProcess.spawn — 11D 实现'); + } + + @override + Future kill({Duration gracePeriod = const Duration(seconds: 3)}) { + throw UnimplementedError('DesktopKernelProcess.kill — 11D 实现'); + } + + @override + bool get isRunning => false; + + @override + ClashApiClient get clashApiClient => + throw UnimplementedError('DesktopKernelProcess.clashApiClient — 11D 实现'); + + @override + Stream get logStream => + throw UnimplementedError('DesktopKernelProcess.logStream — 11D 实现'); +} diff --git a/client/lib/bridge/vpn_bridge.dart b/client/lib/bridge/vpn_bridge.dart new file mode 100644 index 0000000..ce3366d --- /dev/null +++ b/client/lib/bridge/vpn_bridge.dart @@ -0,0 +1,243 @@ +// vpn_bridge.dart — Dart↔原生桥接 API 契约(冻结字段) +// +// ═══════════════════════════════════════════════════════════════ +// 通道名称(Channel names) +// ═══════════════════════════════════════════════════════════════ +// MethodChannel : pangolin/vpn +// EventChannel : pangolin/vpn/status +// EventChannel : pangolin/vpn/stats +// +// ═══════════════════════════════════════════════════════════════ +// MethodChannel 方法签名(Method signatures)—— 不得私改 +// ═══════════════════════════════════════════════════════════════ +// start(String configJson) → void +// stop() → void +// getStatus() → String ("off"|"connecting"|"on"|"error") +// selectOutbound(String tag) → void +// getActiveOutbound() → String tag +// setKillSwitch(bool on) → void +// +// ═══════════════════════════════════════════════════════════════ +// EventChannel 事件 schema —— 不得私改 +// ═══════════════════════════════════════════════════════════════ +// pangolin/vpn/status → String +// 值: "off" | "connecting" | "on" | "error" +// 来源:内核回调(严禁 UI 乐观显示;start 返回 ≠ on) +// +// pangolin/vpn/stats → Map +// { +// "uploadBytes" : int, // 本次隧道累计上传字节 +// "downloadBytes" : int, // 本次隧道累计下载字节 +// "uploadSpeed" : double, // bytes/s 瞬时上行速率 +// "downloadSpeed" : double, // bytes/s 瞬时下行速率 +// "urltestResults" : [ // urltest 延迟结果列表 +// { "tag": String, "delayMs": int }, +// ... +// ] +// } +// 周期:约 1 秒一帧(内核驱动,非固定间隔) +// +// 最后修订:任务 tsk_iP5-_Ztq5THx(桥接 API 面定稿) +// ═══════════════════════════════════════════════════════════════ + +import 'dart:async'; +import 'package:flutter/services.dart'; + +// ── 状态枚举 ──────────────────────────────────────────────────── +// 与 design/flutter/widgets/connect_button.dart VpnStatus 对齐。 +// error 为桥接层扩展态,仅由内核回调产生,UI 层按需处理。 +enum VpnStatus { + off, + connecting, + on, + error; + + /// 从原生侧字符串解析 + static VpnStatus fromString(String s) => switch (s) { + 'off' => VpnStatus.off, + 'connecting' => VpnStatus.connecting, + 'on' => VpnStatus.on, + 'error' => VpnStatus.error, + _ => VpnStatus.error, + }; + + /// 序列化为原生侧字符串 + String toNativeString() => switch (this) { + VpnStatus.off => 'off', + VpnStatus.connecting => 'connecting', + VpnStatus.on => 'on', + VpnStatus.error => 'error', + }; +} + +// ── stats 数据模型 ─────────────────────────────────────────────── + +class UrltestResult { + const UrltestResult({required this.tag, required this.delayMs}); + + final String tag; + final int delayMs; + + factory UrltestResult.fromMap(Map m) => UrltestResult( + tag: m['tag'] as String? ?? '', + delayMs: (m['delayMs'] as num?)?.toInt() ?? -1, + ); + + Map toMap() => {'tag': tag, 'delayMs': delayMs}; + + @override + String toString() => 'UrltestResult(tag=$tag, delayMs=$delayMs)'; +} + +class VpnStatsEvent { + const VpnStatsEvent({ + required this.uploadBytes, + required this.downloadBytes, + required this.uploadSpeed, + required this.downloadSpeed, + required this.urltestResults, + }); + + final int uploadBytes; + final int downloadBytes; + final double uploadSpeed; + final double downloadSpeed; + final List urltestResults; + + factory VpnStatsEvent.fromMap(Map m) => VpnStatsEvent( + uploadBytes: (m['uploadBytes'] as num?)?.toInt() ?? 0, + downloadBytes: (m['downloadBytes'] as num?)?.toInt() ?? 0, + uploadSpeed: (m['uploadSpeed'] as num?)?.toDouble() ?? 0.0, + downloadSpeed: (m['downloadSpeed'] as num?)?.toDouble() ?? 0.0, + urltestResults: ((m['urltestResults'] as List?) + ?.where((e) => e is Map) + .map((e) => UrltestResult.fromMap( + Map.from(e as Map), + )) + .toList()) ?? + const [], + ); + + /// 从平台通道原始 Map(Map)解析,自动转换键类型。 + static VpnStatsEvent fromNativeMap(Map raw) => + VpnStatsEvent.fromMap(raw.cast()); + + Map toMap() => { + 'uploadBytes': uploadBytes, + 'downloadBytes': downloadBytes, + 'uploadSpeed': uploadSpeed, + 'downloadSpeed': downloadSpeed, + 'urltestResults': urltestResults.map((r) => r.toMap()).toList(), + }; + + @override + String toString() => + 'VpnStatsEvent(up=${uploadBytes}B, down=${downloadBytes}B, ' + 'upSpeed=${uploadSpeed.toStringAsFixed(0)}B/s, ' + 'downSpeed=${downloadSpeed.toStringAsFixed(0)}B/s, ' + 'urltest=${urltestResults.length}nodes)'; +} + +// ── 抽象接口 ───────────────────────────────────────────────────── + +/// Dart 侧 VPN 桥接接口。 +/// 实现方:[VpnNativeBridge](真实原生通道)与 [VpnBridgeMock](UI 联调)。 +abstract class VpnBridge { + // ── MethodChannel 方法 ── + + /// 启动 VPN。[configJson] 为 sing-box 兼容 JSON 配置字符串。 + /// 返回不代表连接已建立,实际状态由 [statusStream] 驱动。 + Future start(String configJson); + + /// 停止 VPN。 + Future stop(); + + /// 查询当前状态(一次性拉取,实时监听用 [statusStream])。 + Future getStatus(); + + /// 切换出口节点。[tag] 为 sing-box outbound tag。 + Future selectOutbound(String tag); + + /// 获取当前激活出口节点 tag。 + Future getActiveOutbound(); + + /// 设置 Kill Switch 开关。 + Future setKillSwitch({required bool on}); + + // ── EventChannel 流 ── + + /// 实时 VPN 状态流(来自内核回调)。 + Stream get statusStream; + + /// 约每秒推一帧统计数据流(来自内核计时器)。 + Stream get statsStream; + + /// 释放资源。 + void dispose(); +} + +// ── 原生实现 ───────────────────────────────────────────────────── + +/// 生产实现:使用 Flutter MethodChannel / EventChannel 与原生侧通信。 +class VpnNativeBridge implements VpnBridge { + VpnNativeBridge() + : _method = const MethodChannel('pangolin/vpn'), + _statusRaw = const EventChannel('pangolin/vpn/status'), + _statsRaw = const EventChannel('pangolin/vpn/stats'); + + // 允许测试注入 + VpnNativeBridge.withChannels({ + required MethodChannel method, + required EventChannel status, + required EventChannel stats, + }) : _method = method, + _statusRaw = status, + _statsRaw = stats; + + final MethodChannel _method; + final EventChannel _statusRaw; + final EventChannel _statsRaw; + + @override + Future start(String configJson) => + _method.invokeMethod('start', configJson); + + @override + Future stop() => _method.invokeMethod('stop'); + + @override + Future getStatus() async { + final s = await _method.invokeMethod('getStatus') ?? 'off'; + return VpnStatus.fromString(s); + } + + @override + Future selectOutbound(String tag) => + _method.invokeMethod('selectOutbound', tag); + + @override + Future getActiveOutbound() async => + await _method.invokeMethod('getActiveOutbound') ?? 'auto'; + + @override + Future setKillSwitch({required bool on}) => + _method.invokeMethod('setKillSwitch', on); + + @override + Stream get statusStream => _statusRaw + .receiveBroadcastStream() + .map((e) => VpnStatus.fromString(e?.toString() ?? 'error')); + + @override + Stream get statsStream => _statsRaw + .receiveBroadcastStream() + .where((e) => e is Map) + .map((e) => VpnStatsEvent.fromNativeMap( + Map.from(e as Map), + )); + + @override + void dispose() { + // EventChannel 由 Flutter 引擎管理,无需手动关闭 + } +} diff --git a/client/lib/bridge/vpn_bridge_mock.dart b/client/lib/bridge/vpn_bridge_mock.dart new file mode 100644 index 0000000..3cc075a --- /dev/null +++ b/client/lib/bridge/vpn_bridge_mock.dart @@ -0,0 +1,137 @@ +// vpn_bridge_mock.dart — 假内核实现,供 UI 层先行联调 +// +// 行为: +// start() → 立即 emit connecting,[connectDelay] 后 emit on,启动统计计时器 +// stop() → 立即 emit off,停止统计计时器 +// stats → on 状态下按 [statsInterval] 周期推假数据帧 +// +// 用法示例(UI 联调): +// final bridge = VpnBridgeMock(); +// +// 用法示例(测试): +// final bridge = VpnBridgeMock( +// connectDelay: Duration(milliseconds: 50), +// statsInterval: Duration(milliseconds: 50), +// ); + +import 'dart:async'; +import 'dart:math'; +import 'vpn_bridge.dart'; + +class VpnBridgeMock implements VpnBridge { + VpnBridgeMock({ + this.connectDelay = const Duration(milliseconds: 1500), + this.statsInterval = const Duration(seconds: 1), + }); + + final Duration connectDelay; + final Duration statsInterval; + + // 内部状态 + VpnStatus _status = VpnStatus.off; + String _activeOutbound = 'auto'; + bool _killSwitch = false; + + final _statusCtrl = StreamController.broadcast(); + final _statsCtrl = StreamController.broadcast(); + + Timer? _connectTimer; + Timer? _statsTimer; + + int _tickCount = 0; + final _rng = Random(); + + // ── helpers ─────────────────────────────────────────────────── + + void _emitStatus(VpnStatus s) { + _status = s; + if (!_statusCtrl.isClosed) _statusCtrl.add(s); + } + + void _startStats() { + _tickCount = 0; + _statsTimer?.cancel(); + _statsTimer = Timer.periodic(statsInterval, (_) { + _tickCount++; + if (!_statsCtrl.isClosed) _statsCtrl.add(_fakeTick(_tickCount)); + }); + } + + VpnStatsEvent _fakeTick(int t) { + // 模拟稳定带宽 + 微抖动 + final upSpeed = 10240.0 + _rng.nextDouble() * 2048; + final downSpeed = 51200.0 + _rng.nextDouble() * 8192; + return VpnStatsEvent( + uploadBytes: (upSpeed * t * statsInterval.inMilliseconds / 1000).round(), + downloadBytes: (downSpeed * t * statsInterval.inMilliseconds / 1000).round(), + uploadSpeed: upSpeed, + downloadSpeed: downSpeed, + urltestResults: const [ + UrltestResult(tag: 'hk-01', delayMs: 18), + UrltestResult(tag: 'jp-01', delayMs: 32), + UrltestResult(tag: 'sg-01', delayMs: 54), + ], + ); + } + + // ── VpnBridge methods ───────────────────────────────────────── + + @override + Future start(String configJson) async { + _connectTimer?.cancel(); + _statsTimer?.cancel(); + _emitStatus(VpnStatus.connecting); + _connectTimer = Timer(connectDelay, () { + _emitStatus(VpnStatus.on); + _startStats(); + }); + } + + @override + Future stop() async { + _connectTimer?.cancel(); + _statsTimer?.cancel(); + _connectTimer = null; + _statsTimer = null; + _emitStatus(VpnStatus.off); + } + + @override + Future getStatus() async => _status; + + @override + Future selectOutbound(String tag) async { + _activeOutbound = tag; + // 切换出口时模拟短暂重连 + if (_status == VpnStatus.on) { + _statsTimer?.cancel(); + _emitStatus(VpnStatus.connecting); + _connectTimer = Timer(connectDelay ~/ 2, () { + _emitStatus(VpnStatus.on); + _startStats(); + }); + } + } + + @override + Future getActiveOutbound() async => _activeOutbound; + + @override + Future setKillSwitch({required bool on}) async { + _killSwitch = on; + } + + @override + Stream get statusStream => _statusCtrl.stream; + + @override + Stream get statsStream => _statsCtrl.stream; + + @override + void dispose() { + _connectTimer?.cancel(); + _statsTimer?.cancel(); + _statusCtrl.close(); + _statsCtrl.close(); + } +} diff --git a/client/lib/widgets/connect_button.dart b/client/lib/widgets/connect_button.dart index 2432693..0aed1ff 100644 --- a/client/lib/widgets/connect_button.dart +++ b/client/lib/widgets/connect_button.dart @@ -1,9 +1,12 @@ // connect_button.dart — 核心连接键(三态:off / connecting / on) +// VpnStatus 枚举定义在 lib/bridge/vpn_bridge.dart(含 error 扩展态) import 'package:flutter/material.dart'; +import '../bridge/vpn_bridge.dart' show VpnStatus; import '../pangolin_theme.dart'; import 'pangolin_icons.dart'; -enum VpnStatus { off, connecting, on } +// 重导出便于其他 widget 从 connect_button.dart 引入(向后兼容) +export '../bridge/vpn_bridge.dart' show VpnStatus; class ConnectButton extends StatefulWidget { const ConnectButton({ @@ -47,12 +50,14 @@ class _ConnectButtonState extends State with SingleTickerProvider VpnStatus.off => c.bgSubtle, VpnStatus.connecting => c.accent, VpnStatus.on => c.success, + VpnStatus.error => c.danger, }; final Color fg = s == VpnStatus.off ? c.accent : PangolinColors.white; final IconData icon = switch (s) { VpnStatus.off => PangolinIcons.power, VpnStatus.connecting => PangolinIcons.loader, VpnStatus.on => PangolinIcons.shieldCheck, + VpnStatus.error => PangolinIcons.power, }; final List glow = s == VpnStatus.off diff --git a/client/lib/widgets/home_shell.dart b/client/lib/widgets/home_shell.dart index 7b55a1b..4302393 100644 --- a/client/lib/widgets/home_shell.dart +++ b/client/lib/widgets/home_shell.dart @@ -117,6 +117,7 @@ class _ConnectPage extends StatelessWidget { VpnStatus.off => zh ? '未连接 · 轻点连接' : 'Tap to connect', VpnStatus.connecting => zh ? '连接中…' : 'Connecting…', VpnStatus.on => zh ? '已连接 · 网络已加密' : 'Connected · Encrypted', + VpnStatus.error => zh ? '连接错误' : 'Connection error', }; return Column(children: [ _TopBar(zh: zh, trailing: Text(status == VpnStatus.on ? (zh ? '● 在线' : '● Online') : (zh ? '○ 离线' : '○ Offline'), diff --git a/client/test/bridge/vpn_bridge_mock_test.dart b/client/test/bridge/vpn_bridge_mock_test.dart new file mode 100644 index 0000000..41bc8f6 --- /dev/null +++ b/client/test/bridge/vpn_bridge_mock_test.dart @@ -0,0 +1,264 @@ +// vpn_bridge_mock_test.dart — VpnBridgeMock 单元测试 +// +// 验收条件(任务 tsk_iP5-_Ztq5THx): +// 1. start → connecting → on 事件序列完整流到 Dart 侧 +// 2. stop → off 事件 +// 3. stats 流在 on 状态下周期推帧(每 statsInterval) +// 4. selectOutbound 切换时产生 connecting → on 重连序列 +// 5. getStatus / getActiveOutbound / setKillSwitch 返回正确值 + +import 'dart:async'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pangolin_vpn/bridge/vpn_bridge.dart'; +import 'package:pangolin_vpn/bridge/vpn_bridge_mock.dart'; + +void main() { + // 测试用缩短延迟,避免测试耗时过长 + const connectDelay = Duration(milliseconds: 80); + const statsInterval = Duration(milliseconds: 60); + + VpnBridgeMock newMock() => VpnBridgeMock( + connectDelay: connectDelay, + statsInterval: statsInterval, + ); + + // ── 1. start → connecting → on → stop → off ───────────────── + group('status event sequence', () { + test('start emits connecting immediately', () async { + final mock = newMock(); + final events = []; + final sub = mock.statusStream.listen(events.add); + + await mock.start('{}'); + + expect(events, [VpnStatus.connecting]); + + await sub.cancel(); + mock.dispose(); + }); + + test('start then emits on after connectDelay', () async { + final mock = newMock(); + final events = []; + final sub = mock.statusStream.listen(events.add); + + await mock.start('{}'); + await Future.delayed(connectDelay + const Duration(milliseconds: 20)); + + expect(events, containsAllInOrder([VpnStatus.connecting, VpnStatus.on])); + + await sub.cancel(); + mock.dispose(); + }); + + test('stop after on emits off', () async { + final mock = newMock(); + final events = []; + final sub = mock.statusStream.listen(events.add); + + await mock.start('{}'); + await Future.delayed(connectDelay + const Duration(milliseconds: 20)); + await mock.stop(); + + expect(events.last, VpnStatus.off); + + await sub.cancel(); + mock.dispose(); + }); + + test('full sequence: start→connecting→on→stop→off', () async { + final mock = newMock(); + final events = []; + final sub = mock.statusStream.listen(events.add); + + await mock.start('{}'); + await Future.delayed(connectDelay + const Duration(milliseconds: 20)); + await mock.stop(); + + expect(events, containsAllInOrder([ + VpnStatus.connecting, + VpnStatus.on, + VpnStatus.off, + ])); + + await sub.cancel(); + mock.dispose(); + }); + + test('stop before on transitions: connecting → off (no on emitted)', () async { + final mock = newMock(); + final events = []; + final sub = mock.statusStream.listen(events.add); + + await mock.start('{}'); + // Stop before connectDelay expires + await mock.stop(); + + // Should be: connecting, off — no 'on' + expect(events, containsAllInOrder([VpnStatus.connecting, VpnStatus.off])); + expect(events, isNot(contains(VpnStatus.on))); + + await sub.cancel(); + mock.dispose(); + }); + }); + + // ── 2. stats 流 ───────────────────────────────────────────── + group('stats stream', () { + test('stats stream emits frames after on transition', () async { + final mock = newMock(); + final stats = []; + final sub = mock.statsStream.listen(stats.add); + + await mock.start('{}'); + // Wait for on + at least 2 stats ticks + await Future.delayed( + connectDelay + statsInterval * 2 + const Duration(milliseconds: 20), + ); + + expect(stats, isNotEmpty); + expect(stats.first.uploadSpeed, greaterThan(0)); + expect(stats.first.downloadSpeed, greaterThan(0)); + expect(stats.first.downloadBytes, greaterThan(0)); + + await sub.cancel(); + mock.dispose(); + }); + + test('stats stream stops after stop()', () async { + final mock = newMock(); + final stats = []; + final sub = mock.statsStream.listen(stats.add); + + await mock.start('{}'); + await Future.delayed(connectDelay + statsInterval * 2 + const Duration(milliseconds: 20)); + final countBeforeStop = stats.length; + + await mock.stop(); + await Future.delayed(statsInterval * 3); + final countAfterStop = stats.length; + + expect(countBeforeStop, greaterThan(0)); + // No new frames after stop + expect(countAfterStop, equals(countBeforeStop)); + + await sub.cancel(); + mock.dispose(); + }); + + test('stats have urltestResults', () async { + final mock = newMock(); + final stats = []; + final sub = mock.statsStream.listen(stats.add); + + await mock.start('{}'); + await Future.delayed(connectDelay + statsInterval + const Duration(milliseconds: 20)); + + expect(stats.first.urltestResults, isNotEmpty); + expect(stats.first.urltestResults.first.tag, isNotEmpty); + expect(stats.first.urltestResults.first.delayMs, greaterThan(0)); + + await sub.cancel(); + mock.dispose(); + }); + }); + + // ── 3. selectOutbound ──────────────────────────────────────── + group('selectOutbound', () { + test('selectOutbound while on triggers reconnect sequence', () async { + final mock = newMock(); + final events = []; + final sub = mock.statusStream.listen(events.add); + + await mock.start('{}'); + await Future.delayed(connectDelay + const Duration(milliseconds: 20)); + + // Clear previous events for clarity + events.clear(); + + await mock.selectOutbound('jp-01'); + await Future.delayed(connectDelay + const Duration(milliseconds: 20)); + + expect(events, containsAllInOrder([VpnStatus.connecting, VpnStatus.on])); + + await sub.cancel(); + mock.dispose(); + }); + }); + + // ── 4. MethodChannel stub methods ──────────────────────────── + group('stub methods', () { + test('getStatus returns current status', () async { + final mock = newMock(); + expect(await mock.getStatus(), VpnStatus.off); + + await mock.start('{}'); + expect(await mock.getStatus(), VpnStatus.connecting); + + await Future.delayed(connectDelay + const Duration(milliseconds: 20)); + expect(await mock.getStatus(), VpnStatus.on); + + await mock.stop(); + expect(await mock.getStatus(), VpnStatus.off); + + mock.dispose(); + }); + + test('getActiveOutbound returns default then updated tag', () async { + final mock = newMock(); + expect(await mock.getActiveOutbound(), 'auto'); + + await mock.selectOutbound('hk-01'); + expect(await mock.getActiveOutbound(), 'hk-01'); + + mock.dispose(); + }); + + test('setKillSwitch completes without error', () async { + final mock = newMock(); + await expectLater(mock.setKillSwitch(on: true), completes); + await expectLater(mock.setKillSwitch(on: false), completes); + mock.dispose(); + }); + }); + + // ── 5. VpnStatsEvent schema round-trip ─────────────────────── + group('VpnStatsEvent schema', () { + test('fromMap / toMap round-trip', () { + final original = const VpnStatsEvent( + uploadBytes: 1024, + downloadBytes: 4096, + uploadSpeed: 512.0, + downloadSpeed: 2048.0, + urltestResults: [ + UrltestResult(tag: 'hk-01', delayMs: 18), + UrltestResult(tag: 'jp-01', delayMs: 32), + ], + ); + // toMap() returns Map — same type as fromMap expects + final decoded = VpnStatsEvent.fromMap(original.toMap()); + + expect(decoded.uploadBytes, original.uploadBytes); + expect(decoded.downloadBytes, original.downloadBytes); + expect(decoded.uploadSpeed, original.uploadSpeed); + expect(decoded.downloadSpeed, original.downloadSpeed); + expect(decoded.urltestResults.length, 2); + expect(decoded.urltestResults[0].tag, 'hk-01'); + expect(decoded.urltestResults[1].delayMs, 32); + }); + + test('VpnStatus.fromString handles all known values', () { + expect(VpnStatus.fromString('off'), VpnStatus.off); + expect(VpnStatus.fromString('connecting'), VpnStatus.connecting); + expect(VpnStatus.fromString('on'), VpnStatus.on); + expect(VpnStatus.fromString('error'), VpnStatus.error); + expect(VpnStatus.fromString('unknown'), VpnStatus.error); // fallback + }); + + test('VpnStatus.toNativeString round-trips', () { + for (final s in VpnStatus.values) { + expect(VpnStatus.fromString(s.toNativeString()), s); + } + }); + }); +}