Merge branch 'main' into feature/pangolin-ios
ci-pangolin / Lint — shellcheck (push) Has been cancelled
ci-pangolin / OpenAPI Sync Check (push) Has been cancelled
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Has been cancelled
ci-pangolin / Flutter — analyze + test (push) Has been cancelled

# Conflicts:
#	client/lib/bridge/vpn_bridge_provider.dart
#	docs/index.html
This commit is contained in:
wangjia
2026-06-23 10:05:20 +08:00
23 changed files with 2208 additions and 459 deletions
+7 -3
View File
@@ -5,12 +5,16 @@
# SINGBOX_VERSION : 改为目标 GitHub tag(如 v1.14.0 # SINGBOX_VERSION : 改为目标 GitHub tag(如 v1.14.0
# 同步更新 fetch-desktop-bin.sh 中的桌面 SHA256 说明 # 同步更新 fetch-desktop-bin.sh 中的桌面 SHA256 说明
# GO_VERSION : 与官方 SFA/SFI CI 保持同步(go1.x.y # GO_VERSION : 与官方 SFA/SFI CI 保持同步(go1.x.y
# GOMOBILE_VERSION : 对应 golang.org/x/mobile 的 pseudo-version 或 tag # GOMOBILE_MODULE : gomobile 模块路径。sing-box 官方用 SagerNet fork
# go install golang.org/x/mobile/cmd/gomobile@<GOMOBILE_VERSION> # (github.com/sagernet/gomobile),非上游 golang.org/x/mobile。
# 对照 sing-box <tag> 的 go.mod 与 cmd/internal/build_libbox。
# GOMOBILE_VERSION : GOMOBILE_MODULE 的版本 tag(与 sing-box go.mod 同步)
# go install ${GOMOBILE_MODULE}/cmd/gomobile@<GOMOBILE_VERSION>
# WINTUN_VERSION : Wintun 驱动版本(Windows 专用),保持与 sing-box 推荐版本同步 # WINTUN_VERSION : Wintun 驱动版本(Windows 专用),保持与 sing-box 推荐版本同步
# 下载: https://www.wintun.net/builds/wintun-<WINTUN_VERSION>.zip # 下载: https://www.wintun.net/builds/wintun-<WINTUN_VERSION>.zip
SINGBOX_VERSION=v1.13.12 SINGBOX_VERSION=v1.13.12
GO_VERSION=1.24.3 GO_VERSION=1.24.3
GOMOBILE_VERSION=v0.0.0-20240604150348-70c4c5da7d30 GOMOBILE_MODULE=github.com/sagernet/gomobile
GOMOBILE_VERSION=v0.1.12
WINTUN_VERSION=0.14.1 WINTUN_VERSION=0.14.1
+36 -5
View File
@@ -46,7 +46,10 @@ OUT_AAR="${DIST_DIR}/libbox.aar"
BUILD_SRC="${SCRIPT_DIR}/.build/singbox" BUILD_SRC="${SCRIPT_DIR}/.build/singbox"
# ── Build tags ──────────────────────────────────────────────────────────────── # ── Build tags ────────────────────────────────────────────────────────────────
BUILD_TAGS="with_quic,with_utls,with_clash_api,with_gvisor" # 功能 tags(最小集:REALITY/VLESS + Hysteria2(quic) + uTLS + Clash API + gVisor)。
# badlinkname,tfogo_checklinkname0 = Go 1.23+ 的 //go:linkname 限制放行
# (对齐 sing-box cmd/internal/build_libbox 的 sharedTags,缺则 Go 1.24 编译失败)。
BUILD_TAGS="with_quic,with_utls,with_clash_api,with_gvisor,badlinkname,tfogo_checklinkname0"
# 取消注释以增加功能(会增大 .so 体积): # 取消注释以增加功能(会增大 .so 体积):
# BUILD_TAGS="${BUILD_TAGS},with_grpc" # BUILD_TAGS="${BUILD_TAGS},with_grpc"
# BUILD_TAGS="${BUILD_TAGS},with_ech" # BUILD_TAGS="${BUILD_TAGS},with_ech"
@@ -81,10 +84,26 @@ else
printf '⚠ 未设置 ANDROID_NDK_HOME / ANDROID_HOMEgomobile init 可能失败\n' >&2 printf '⚠ 未设置 ANDROID_NDK_HOME / ANDROID_HOMEgomobile init 可能失败\n' >&2
fi fi
# ── 安装 gomobile ───────────────────────────────────────────────────────────── # ── JDKgomobile bind 末段要 javac/jar 编译 Java 绑定)─────────────────────────
printf '==> 安装 gomobile@%s…\n' "${GOMOBILE_VERSION}" # macOS 的 /usr/bin/javac 仅为桩(无系统 JDK 时报 "Unable to locate a Java Runtime")。
go install "golang.org/x/mobile/cmd/gomobile@${GOMOBILE_VERSION}" # 若未显式设 JAVA_HOME,优先用 Android Studio 自带 JBROpenJDK 21)。
go install "golang.org/x/mobile/cmd/gobind@${GOMOBILE_VERSION}" if [[ -z "${JAVA_HOME:-}" ]]; then
AS_JBR="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
if [[ -x "${AS_JBR}/bin/javac" ]]; then
export JAVA_HOME="${AS_JBR}"
export PATH="${JAVA_HOME}/bin:${PATH}"
printf ' JDK: %sAndroid Studio JBR\n' "${JAVA_HOME}"
else
printf '⚠ 未设 JAVA_HOME 且未找到 Android Studio JBRgomobile bind 末段 javac 可能失败\n' >&2
fi
else
printf ' JDK: %sJAVA_HOME\n' "${JAVA_HOME}"
fi
# ── 安装 gomobileSagerNet fork,见 VERSION 注释)────────────────────────────
printf '==> 安装 %s/cmd/gomobile@%s…\n' "${GOMOBILE_MODULE}" "${GOMOBILE_VERSION}"
go install "${GOMOBILE_MODULE}/cmd/gomobile@${GOMOBILE_VERSION}"
go install "${GOMOBILE_MODULE}/cmd/gobind@${GOMOBILE_VERSION}"
printf '==> gomobile init…\n' printf '==> gomobile init…\n'
gomobile init gomobile init
@@ -117,10 +136,22 @@ printf ' tags : %s\n' "${BUILD_TAGS}"
mkdir -p "${DIST_DIR}" mkdir -p "${DIST_DIR}"
# ldflags 对齐 sing-box cmd/internal/build_libbox 的 sharedFlags
# -checklinkname=0 放行 badlinkname 的 //go:linknameGo 1.23+ 默认 checklinkname=1
# 会拒 os.checkPidfdOnce 等内部符号引用,缺则链接失败)
# defaultGODEBUG=multipathtcp=0 关 MPTCP(与官方一致)
# -X ...constant.Version 版本戳
# -s -w -buildid= 裁剪符号表,产物更小
LDFLAGS="-X github.com/sagernet/sing-box/constant.Version=${SINGBOX_VERSION} -X internal/godebug.defaultGODEBUG=multipathtcp=0 -s -w -buildid= -checklinkname=0"
( (
cd "${BUILD_SRC}" cd "${BUILD_SRC}"
go mod download go mod download
gomobile bind \ gomobile bind \
-v \
-trimpath \
-buildvcs=false \
-ldflags "${LDFLAGS}" \
-target android/arm64,android/arm,android/amd64 \ -target android/arm64,android/arm,android/amd64 \
-androidapi 21 \ -androidapi 21 \
-tags "${BUILD_TAGS}" \ -tags "${BUILD_TAGS}" \
+8 -28
View File
@@ -1,3 +1,9 @@
plugins {
id "com.android.application"
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
}
def localProperties = new Properties() def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties') def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) { if (localPropertiesFile.exists()) {
@@ -6,11 +12,6 @@ if (localPropertiesFile.exists()) {
} }
} }
def flutterRoot = localProperties.getProperty('flutter.sdk')
if (flutterRoot == null) {
throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
}
def flutterVersionCode = localProperties.getProperty('flutter.versionCode') def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) { if (flutterVersionCode == null) {
flutterVersionCode = '1' flutterVersionCode = '1'
@@ -21,10 +22,6 @@ if (flutterVersionName == null) {
flutterVersionName = '1.0' flutterVersionName = '1.0'
} }
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
android { android {
namespace "com.pangolin.pangolin_vpn" namespace "com.pangolin.pangolin_vpn"
compileSdkVersion flutter.compileSdkVersion compileSdkVersion flutter.compileSdkVersion
@@ -57,17 +54,6 @@ android {
signingConfig signingConfigs.debug signingConfig signingConfigs.debug
} }
} }
// libbox.aar 包含 arm64-v8a / armeabi-v7a / x86_64 三个 ABI
// 若只需调试,可缩减 abiFilters 以加快构建速度。
// splits {
// abi {
// enable true
// reset()
// include 'arm64-v8a', 'x86_64'
// universalApk true
// }
// }
} }
flutter { flutter {
@@ -75,21 +61,15 @@ flutter {
} }
dependencies { dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
// ── sing-box libboxgomobile AAR)────────────────────────────── // ── sing-box libboxgomobile AAR)──────────────────────────────
// 产物由 app/kernel/build-android.sh 生成: // 产物由 app/kernel/build-android.sh 生成:
// cd app/kernel && ./build-android.sh // cd app/kernel && ./build-android.sh
// 产物路径(相对于本 build.gradle):../../../app/kernel/dist/android/libbox.aar // 路径:client/android/app → ../../.. → repo root → app/kernel/dist/android/
// // Kotlin stdlib 由 kotlin-android 插件自动引入,无需显式声明。
// 若文件不存在,构建会报错:请先运行 build-android.sh 生成产物。
// 路径说明:client/android/app → ../../.. → repo root → app/kernel/dist/android/
def libboxAar = file("${projectDir}/../../../app/kernel/dist/android/libbox.aar") def libboxAar = file("${projectDir}/../../../app/kernel/dist/android/libbox.aar")
if (libboxAar.exists()) { if (libboxAar.exists()) {
implementation files(libboxAar) implementation files(libboxAar)
} else { } else {
// AAR 尚未构建 — 保留占位;IDE 会报红线,但不影响非 libbox 代码编辑。
// 运行 `app/kernel/build-android.sh` 后重新 sync 即可。
logger.warn("⚠ libbox.aar not found at ${libboxAar.absolutePath}") logger.warn("⚠ libbox.aar not found at ${libboxAar.absolutePath}")
logger.warn(" Run: cd app/kernel && ./build-android.sh") logger.warn(" Run: cd app/kernel && ./build-android.sh")
} }
@@ -4,6 +4,9 @@
<!-- 网络权限(隧道流量) --> <!-- 网络权限(隧道流量) -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<!-- 默认网络监听需要(DefaultNetworkMonitor 用 ConnectivityManager 取默认接口) -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- 前台服务(VPN 持续运行所需,API 28+) --> <!-- 前台服务(VPN 持续运行所需,API 28+) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
@@ -0,0 +1,124 @@
package com.pangolin.pangolin_vpn
import android.content.Context
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.util.Log
import libbox.InterfaceUpdateListener
import java.net.NetworkInterface
/**
* DefaultNetworkMonitor — 上报「底层默认网络」给 sing-box 内核。
*
* sing-box 在 Android 上不自己枚举接口(PlatformInterface.usePlatformAutoDetectInterfaceControl=true),
* 它通过 [InterfaceUpdateListener.updateDefaultInterface] 得知当前默认出口接口(名字+index),
* 据此把出站 socket 绑定到底层物理网络(避免 TUN 路由环路)。
*
* **缺这个监听 → 内核启动期拿不到默认接口 → 报 "no available network interface",隧道连不上。**
* 故 [start] 必须在 CommandServer.startOrReloadService 之前调用,并把首个默认网络同步上报。
*
* 自包含实现:直接用 ConnectivityManager.registerNetworkCallbackAPI 21+),
* 不依赖 SFA 的 Application/协程框架。
*/
object DefaultNetworkMonitor {
private const val TAG = "PangolinNetMonitor"
@Volatile private var listener: InterfaceUpdateListener? = null
@Volatile private var defaultNetwork: Network? = null
private var connectivity: ConnectivityManager? = null
private var callback: ConnectivityManager.NetworkCallback? = null
/** 注册默认网络回调,并立即同步一次当前网络。幂等。 */
fun start(context: Context) {
if (callback != null) return
val cm = context.applicationContext
.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
connectivity = cm
val cb = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
defaultNetwork = network
notifyUpdate(network)
}
override fun onLinkPropertiesChanged(network: Network, lp: LinkProperties) {
defaultNetwork = network
notifyUpdate(network)
}
override fun onLost(network: Network) {
if (defaultNetwork == network) {
defaultNetwork = null
notifyUpdate(null)
}
}
}
callback = cb
// 要求一个具备 INTERNET 能力的网络(等价于默认网络)。registerNetworkCallback 支持 API 21+。
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
try {
cm.registerNetworkCallback(request, cb)
} catch (e: Exception) {
Log.e(TAG, "registerNetworkCallback failed: $e")
}
// 同步当前活动网络(回调可能不会立刻触发 onAvailable)。
val active = cm.activeNetwork
if (active != null) {
defaultNetwork = active
notifyUpdate(active)
}
}
fun stop() {
val cm = connectivity
val cb = callback
if (cm != null && cb != null) {
try { cm.unregisterNetworkCallback(cb) } catch (_: Exception) {}
}
callback = null
connectivity = null
defaultNetwork = null
}
/** 内核注册/注销监听(PlatformInterface.start/closeDefaultInterfaceMonitor 调用)。 */
fun setListener(l: InterfaceUpdateListener?) {
listener = l
if (l != null) notifyUpdate(defaultNetwork)
}
/** 把网络的接口名 + index 上报内核;网络为 null 时上报空("",-1)。 */
private fun notifyUpdate(network: Network?) {
val l = listener ?: return
if (network == null) {
l.updateDefaultInterface("", -1, false, false)
return
}
val cm = connectivity ?: return
// LinkProperties 可能短暂为 null,重试几次拿接口名。
for (i in 0 until 10) {
val lp = cm.getLinkProperties(network)
val name = lp?.interfaceName
if (name == null) {
try { Thread.sleep(100) } catch (_: InterruptedException) {}
continue
}
val index = try {
NetworkInterface.getByName(name)?.index ?: -1
} catch (_: Exception) {
-1
}
l.updateDefaultInterface(name, index, false, false)
return
}
Log.w(TAG, "notifyUpdate: interface name unavailable after retries")
}
}
@@ -68,14 +68,14 @@ class MainActivity : FlutterActivity() {
} }
"selectOutbound" -> { "selectOutbound" -> {
val tag = call.arguments as? String ?: "auto" val tag = call.arguments as? String ?: "auto"
Log.d(TAG, "selectOutbound: tag=$tag (stub — 11G)") PangolinVpnService.instance?.selectOutbound(tag)
// TODO(11G): 通过 libbox CommandClient 切换出口 Log.d(TAG, "selectOutbound: tag=$tag")
result.success(null) result.success(null)
} }
"getActiveOutbound" -> { "getActiveOutbound" -> {
Log.d(TAG, "getActiveOutbound (stub — 11G)") val active = PangolinVpnService.instance?.currentActiveOutbound() ?: "auto"
// TODO(11G): 从 libbox CommandClient 查询当前出口 tag Log.d(TAG, "getActiveOutbound -> $active")
result.success("auto") result.success(active)
} }
"setKillSwitch" -> { "setKillSwitch" -> {
val on = call.arguments as? Boolean ?: false val on = call.arguments as? Boolean ?: false
@@ -6,57 +6,69 @@ import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.TrafficStats
import android.net.VpnService import android.net.VpnService
import android.os.Build import android.os.Build
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.system.OsConstants
import android.util.Log import android.util.Log
import go.libbox.BoxService import go.Seq
import go.libbox.CommandClient import libbox.CommandClient
import go.libbox.CommandClientHandler import libbox.CommandClientHandler
import go.libbox.CommandClientOptions import libbox.CommandClientOptions
import go.libbox.Libbox import libbox.CommandServer
import go.libbox.OutboundGroupIterator import libbox.CommandServerHandler
import go.libbox.PlatformInterface import libbox.ConnectionEvents
import go.libbox.ProcessInfo import libbox.ConnectionOwner
import go.libbox.StatusMessage import libbox.InterfaceUpdateListener
import go.libbox.StringIterator import libbox.Libbox
import go.libbox.TunOptions import libbox.LocalDNSTransport
import java.util.concurrent.Executors import libbox.LogIterator
import java.util.concurrent.ScheduledExecutorService import libbox.NetworkInterfaceIterator
import java.util.concurrent.TimeUnit import libbox.Notification as LibboxNotification
import libbox.OutboundGroupIterator
import libbox.OverrideOptions
import libbox.PlatformInterface
import libbox.SetupOptions
import libbox.StatusMessage
import libbox.StringIterator
import libbox.SystemProxyStatus
import libbox.TunOptions
import libbox.WIFIState
import java.util.Collections
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
/** /**
* PangolinVpnService — VPN 前台服务,封装 sing-box libbox 隧道。 * PangolinVpnService — VPN 前台服务,封装 sing-box libbox 隧道。
* *
* ## 生命周期 * ## 架构(sing-box 1.13.x libboxCommandServer 模型)
* START → handleStart() → 后台线程 → startLibbox() → [BoxService.start()] * Libbox.setup(SetupOptions) —— 进程级一次,设可写目录 + 命令 socket
* → VpnEventBus.postStatus("on") * CommandServer(handler, platformInterface).start()
* STOP → doStop() → [BoxService.close()] → 关 TUN fd → postStatus("off") * commandServer.startOrReloadService(configJson, OverrideOptions()) —— 起内核,回调 openTun
* REVOKE → onRevoke() → postStatus("error") → doStop() * 停止:closeService() → close()
* *
* ## 通信 * 本类同时实现 [PlatformInterface]openTun/protect/接口监听…)与 [CommandServerHandler]。
* Dart 通过 [MainActivity] MethodChannel 发送 start/stop intent * 统计与切节点经独立的 [CommandClient](同进程连内核命令 socket)
* 状态与统计经 [VpnEventBus] 推回 Flutter EventChannel。
* *
* ## 注意:libbox.aar API * ## 与 Dart 通信
* 依赖 app/kernel/dist/android/libbox.aar(由 build-android.sh 产出)。 * 状态/统计经 [VpnEventBus] 推回 Flutter EventChannel(契约见 lib/bridge/vpn_bridge.dart)。
* 方法签名注释标注 "// libbox API" 处若编译失败,请对照实际 libbox.aar 中 * selectOutbound/getActiveOutbound 由 [MainActivity] 通过 [instance] 调用。
* go.libbox.* 的 javadoc 微调参数名;整体架构不变。
*/ */
class PangolinVpnService : VpnService() { class PangolinVpnService : VpnService(), PlatformInterface, CommandServerHandler {
companion object { companion object {
private const val TAG = "PangolinVpnService" private const val TAG = "PangolinVpnService"
const val ACTION_START = "com.pangolin.vpn.START" const val ACTION_START = "com.pangolin.vpn.START"
const val ACTION_STOP = "com.pangolin.vpn.STOP" const val ACTION_STOP = "com.pangolin.vpn.STOP"
const val EXTRA_CONFIG = "config_json" const val EXTRA_CONFIG = "config_json"
private const val NOTIFICATION_CHANNEL_ID = "pangolin_vpn_channel" private const val NOTIFICATION_CHANNEL_ID = "pangolin_vpn_channel"
private const val NOTIFICATION_ID = 1001 private const val NOTIFICATION_ID = 1001
/** 同进程内供 MainActivity 调用 selectOutbound/getActiveOutbound。 */
@Volatile var instance: PangolinVpnService? = null
private set
fun startVpn(context: Context, configJson: String) { fun startVpn(context: Context, configJson: String) {
val intent = Intent(context, PangolinVpnService::class.java).apply { val intent = Intent(context, PangolinVpnService::class.java).apply {
action = ACTION_START action = ACTION_START
@@ -67,7 +79,6 @@ class PangolinVpnService : VpnService() {
} else { } else {
context.startService(intent) context.startService(intent)
} }
Log.d(TAG, "startVpn: intent dispatched, configLen=${configJson.length}")
} }
fun stopVpn(context: Context) { fun stopVpn(context: Context) {
@@ -75,37 +86,35 @@ class PangolinVpnService : VpnService() {
action = ACTION_STOP action = ACTION_STOP
} }
context.startService(intent) context.startService(intent)
Log.d(TAG, "stopVpn: intent dispatched")
} }
} }
// ── 内核与 TUN 状态 ─────────────────────────────────────────── // ── 内核与 TUN 状态 ───────────────────────────────────────────
@Volatile private var commandServer: CommandServer? = null
@Volatile private var statsClient: CommandClient? = null
@Volatile private var tunPfd: ParcelFileDescriptor? = null
/** libbox BoxService 内核实例(主线程以外创建,通过 volatile 保证可见性) */ /** 最近一次 writeGroups 缓存的「代理组当前出口」与 urltest 延迟,供 getActiveOutbound/stats 用。 */
@Volatile private var boxService: BoxService? = null @Volatile private var activeOutbound: String = "auto"
@Volatile private var latestUrltest: List<Pair<String, Int>> = emptyList()
/** TUN 文件描述符持有者(VpnService.Builder.establish() 返回值) */ /** 缓存的「可手动选择的出口组」tag(selector 组)。selectOutbound 据此切换。 */
@Volatile var currentTunPfd: ParcelFileDescriptor? = null @Volatile private var selectableGroup: String = ""
/** libbox CommandClient(统计与状态轮询) */
@Volatile private var commandClient: CommandClient? = null
/** 兜底统计定时器(CommandClient 不可用时启用 TrafficStats 方案) */
private var statsExecutor: ScheduledExecutorService? = null
/** 防止 doStop 重入 */
private val stopping = AtomicBoolean(false) private val stopping = AtomicBoolean(false)
private var libboxSetupDone = false
// ── 生命周期 ──────────────────────────────────────────────── // ── 生命周期 ────────────────────────────────────────────────
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Log.i(TAG, "onCreate") instance = this
// gomobile 绑定需要 Android Context(否则部分调用崩溃)。
Seq.setContext(applicationContext)
createNotificationChannel() createNotificationChannel()
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.i(TAG, "onStartCommand: action=${intent?.action}")
return when (intent?.action) { return when (intent?.action) {
ACTION_START -> { ACTION_START -> {
val configJson = intent.getStringExtra(EXTRA_CONFIG) ?: "{}" val configJson = intent.getStringExtra(EXTRA_CONFIG) ?: "{}"
@@ -116,23 +125,19 @@ class PangolinVpnService : VpnService() {
doStop() doStop()
START_NOT_STICKY START_NOT_STICKY
} }
else -> { else -> START_NOT_STICKY
Log.w(TAG, "onStartCommand: unknown action, falling through")
START_NOT_STICKY
}
} }
} }
override fun onRevoke() { override fun onRevoke() {
// 系统撤销 VPN 权限(如用户在设置里关闭)→ 推 error 再清理
Log.w(TAG, "onRevoke: permission revoked by system") Log.w(TAG, "onRevoke: permission revoked by system")
VpnEventBus.postStatus("error") VpnEventBus.postStatus("error")
doStop() doStop()
} }
override fun onDestroy() { override fun onDestroy() {
Log.i(TAG, "onDestroy")
doStop() doStop()
instance = null
super.onDestroy() super.onDestroy()
} }
@@ -143,12 +148,12 @@ class PangolinVpnService : VpnService() {
startForeground(NOTIFICATION_ID, buildNotification("正在连接…")) startForeground(NOTIFICATION_ID, buildNotification("正在连接…"))
VpnEventBus.postStatus("connecting") VpnEventBus.postStatus("connecting")
// libbox 内核启动必须在后台线程(不能阻塞 onStartCommand // 内核启动必须在后台线程(不能阻塞 onStartCommandopenTun 回调会同步等 TUN 建好)。
Thread { Thread {
try { try {
startLibbox(configJson) startKernel(configJson)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "startLibbox failed: $e", e) Log.e(TAG, "startKernel failed: $e", e)
VpnEventBus.postStatus("error") VpnEventBus.postStatus("error")
updateNotification("连接失败") updateNotification("连接失败")
doStop() doStop()
@@ -156,305 +161,313 @@ class PangolinVpnService : VpnService() {
}.apply { name = "pangolin-libbox-start" }.start() }.apply { name = "pangolin-libbox-start" }.start()
} }
/** private fun startKernel(configJson: String) {
* 在后台线程内: ensureLibboxSetup()
* 1. 构造 PlatformInterfaceinner class,持有 VpnService 引用)
* 2. 新建 BoxService 并 start()(内核会回调 openTun 建 TUN 接口)
* 3. 启动 CommandClient 轮询统计
* 4. 推送 "on" 状态
*/
private fun startLibbox(configJson: String) {
val platform = PangolinPlatformInterface()
// ── libbox API: Libbox.newBoxService(platformInterface, configJson, needBuildConfig) // 先起默认接口监听,再起内核——否则内核启动期拿不到默认出口,报 no available network interface。
// 如果编译报错,可能 API 为: new BoxService(platform, configJson) DefaultNetworkMonitor.start(applicationContext)
// 或: BoxService.newService(platform, configJson)
val service = Libbox.newBoxService(platform, configJson, false) // libbox API
service.start() // libbox API
boxService = service
Log.i(TAG, "BoxService started")
// 启动统计客户端 val server = Libbox.newCommandServer(this, this)
startCommandClientOrFallback() server.start()
commandServer = server
server.startOrReloadService(configJson, OverrideOptions())
Log.i(TAG, "sing-box service started")
startStatsClient()
// 更新通知与状态
updateNotification("加速已开启") updateNotification("加速已开启")
VpnEventBus.postStatus("on") VpnEventBus.postStatus("on")
} }
// ── 统计:CommandClient + TrafficStats 兜底 ─────────────────── private fun ensureLibboxSetup() {
if (libboxSetupDone) return
val base = filesDir.absolutePath
val work = filesDir.absolutePath
val temp = cacheDir.absolutePath
val options = SetupOptions().apply {
basePath = base
workingPath = work
tempPath = temp
}
Libbox.setup(options)
libboxSetupDone = true
Log.i(TAG, "Libbox.setup done (base=$base)")
}
/** // ── 统计 + 切节点:CommandClient ──────────────────────────────
* 优先使用 libbox CommandClient 获取精确统计;若连接失败则退回 Android TrafficStats。
*/ private fun startStatsClient() {
private fun startCommandClientOrFallback() {
try { try {
startCommandClient() val options = CommandClientOptions().apply {
statusInterval = 1_000_000_000L // 1s(纳秒)
addCommand(Libbox.CommandStatus)
addCommand(Libbox.CommandGroup) // 取出口组 → getActiveOutbound + urltest 延迟
}
val client = Libbox.newCommandClient(StatsHandler(), options)
client.connect()
statsClient = client
Log.i(TAG, "stats CommandClient connected")
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "CommandClient unavailable ($e), falling back to TrafficStats") Log.w(TAG, "stats CommandClient failed: $e")
startTrafficStatsFallback()
} }
} }
private fun startCommandClient() { /** 由 MainActivity(同进程)调用:切换出口节点到 [tag](在缓存的可选组内)。 */
val options = CommandClientOptions() fun selectOutbound(tag: String) {
// Command 0 = STATUS(含流量统计);间隔 1 秒(纳秒) val group = selectableGroup
// libbox API: options.Command / options.StatusInterval 为 gomobile 暴露的字段 if (group.isEmpty()) {
options.command = 0 // libbox API field Log.w(TAG, "selectOutbound: 无可选出口组(config 可能只有 urltest,无 selector)")
options.statusInterval = 1_000_000_000L // libbox API field: nanoseconds return
}
val handler = object : CommandClientHandler { try {
override fun connected() { statsClient?.selectOutbound(group, tag)
Log.d(TAG, "CommandClient connected") Log.i(TAG, "selectOutbound(group=$group, tag=$tag)")
} } catch (e: Exception) {
override fun disconnected(message: String) { Log.w(TAG, "selectOutbound failed: $e")
Log.d(TAG, "CommandClient disconnected: $message")
// 断开后不改变 VPN 状态——隧道仍在运行,仅统计中断
}
override fun writeLog(message: String) {
Log.v("libbox", message)
}
override fun writeStatus(status: StatusMessage) {
// libbox API: StatusMessage 提供累计流量与瞬时速率
val stats = mapOf<String, Any>(
"uploadBytes" to (status.uploadTotal()), // libbox API
"downloadBytes" to (status.downloadTotal()), // libbox API
"uploadSpeed" to (status.uploadSpeed().toDouble()), // libbox API
"downloadSpeed" to (status.downloadSpeed().toDouble()), // libbox API
"urltestResults" to emptyList<Any>()
)
VpnEventBus.postStats(stats)
}
override fun writeGroups(groups: OutboundGroupIterator) { /* 暂不处理 urltest 延迟 */ }
override fun initializeClashMode(modeList: StringIterator, currentMode: String) {}
override fun updateClashMode(newMode: String) {}
} }
// libbox API: Libbox.newCommandClient(handler, options)
val client = Libbox.newCommandClient(handler, options) // libbox API
client.connect() // libbox API
commandClient = client
Log.i(TAG, "CommandClient started")
} }
/** fun currentActiveOutbound(): String = activeOutbound
* 兜底:用 Android TrafficStats API 按 UID 统计网络流量。
* 由于所有流量都走 VPN,UID 统计近似等于隧道流量。
*/
private fun startTrafficStatsFallback() {
val uid = android.os.Process.myUid()
var lastRx = TrafficStats.getUidRxBytes(uid)
var lastTx = TrafficStats.getUidTxBytes(uid)
var totalRx = 0L
var totalTx = 0L
statsExecutor = Executors.newSingleThreadScheduledExecutor().also { exec -> private inner class StatsHandler : CommandClientHandler {
exec.scheduleAtFixedRate({ override fun connected() { Log.d(TAG, "stats client connected") }
if (stopping.get()) return@scheduleAtFixedRate override fun disconnected(message: String?) { Log.d(TAG, "stats client disconnected: $message") }
try { override fun clearLogs() {}
val rx = TrafficStats.getUidRxBytes(uid).let { if (it < 0) 0L else it } override fun writeLogs(messageList: LogIterator?) {}
val tx = TrafficStats.getUidTxBytes(uid).let { if (it < 0) 0L else it } override fun setDefaultLogLevel(level: Int) {}
val rxDiff = (rx - lastRx).coerceAtLeast(0) override fun initializeClashMode(modeList: StringIterator?, currentMode: String?) {}
val txDiff = (tx - lastTx).coerceAtLeast(0) override fun updateClashMode(newMode: String?) {}
totalRx += rxDiff override fun writeConnectionEvents(message: ConnectionEvents?) {}
totalTx += txDiff
lastRx = rx
lastTx = tx
val stats = mapOf<String, Any>( override fun writeStatus(message: StatusMessage) {
"uploadBytes" to totalTx, val stats = mapOf<String, Any>(
"downloadBytes" to totalRx, "uploadBytes" to message.uplinkTotal,
"uploadSpeed" to txDiff.toDouble(), "downloadBytes" to message.downlinkTotal,
"downloadSpeed" to rxDiff.toDouble(), "uploadSpeed" to message.uplink.toDouble(),
"urltestResults" to emptyList<Any>() "downloadSpeed" to message.downlink.toDouble(),
) "urltestResults" to latestUrltest.map {
VpnEventBus.postStats(stats) mapOf<String, Any>("tag" to it.first, "delayMs" to it.second)
} catch (e: Exception) {
Log.w(TAG, "TrafficStats error: $e")
} }
}, 1L, 1L, TimeUnit.SECONDS) )
VpnEventBus.postStats(stats)
}
override fun writeGroups(groups: OutboundGroupIterator?) {
if (groups == null) return
val urltest = mutableListOf<Pair<String, Int>>()
var selected = activeOutbound
try {
while (groups.hasNext()) {
val group = groups.next()
// 记录可选组的当前出口(如 selector / urltest 的 selected)。
if (group.selected.isNotEmpty()) selected = group.selected
if (group.selectable) selectableGroup = group.tag
val items = group.items
while (items.hasNext()) {
val item = items.next()
urltest.add(item.tag to item.getURLTestDelay())
}
}
} catch (e: Exception) {
Log.w(TAG, "writeGroups parse error: $e")
}
latestUrltest = urltest
activeOutbound = selected
} }
Log.i(TAG, "TrafficStats fallback started, uid=$uid")
} }
// ── 停止流程 ───────────────────────────────────────────────── // ── 停止流程 ─────────────────────────────────────────────────
/**
* 幂等停止:关闭统计 → 关闭 BoxService → 关闭 TUN fd → 停前台通知。
* 由多个入口(ACTION_STOP / onRevoke / onDestroy)调用,通过 [stopping] 防止重入。
*/
private fun doStop() { private fun doStop() {
if (!stopping.compareAndSet(false, true)) { if (!stopping.compareAndSet(false, true)) return
Log.d(TAG, "doStop: already stopping, skip") Log.i(TAG, "doStop")
return
}
Log.i(TAG, "doStop: shutting down tunnel")
// 1. 停统计(先于 BoxService,避免最后一帧读到已关闭的 fd) try { statsClient?.disconnect() } catch (_: Exception) {}
statsExecutor?.apply { statsClient = null
shutdownNow()
try { awaitTermination(500, TimeUnit.MILLISECONDS) } catch (_: InterruptedException) {}
}
statsExecutor = null
try { try { commandServer?.closeService() } catch (e: Exception) { Log.w(TAG, "closeService: $e") }
commandClient?.disconnect() // libbox API try { commandServer?.close() } catch (e: Exception) { Log.w(TAG, "close: $e") }
} catch (e: Exception) { commandServer = null
Log.w(TAG, "commandClient.disconnect failed: $e")
}
commandClient = null
// 2. 关闭 BoxService 内核(会在内部关闭它持有的 TUN fd 引用) DefaultNetworkMonitor.stop()
try {
boxService?.close() // libbox API
} catch (e: Exception) {
Log.w(TAG, "boxService.close failed: $e")
}
boxService = null
// 3. 关闭 ParcelFileDescriptor(持有底层 fd;必须在 BoxService.close 之后) try { tunPfd?.close() } catch (_: Exception) {}
try { tunPfd = null
currentTunPfd?.close()
} catch (e: Exception) {
Log.w(TAG, "tunPfd.close failed: $e")
}
currentTunPfd = null
// 4. 停前台 + 自我停止
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
stopForeground(true) stopForeground(true)
stopSelf() stopSelf()
VpnEventBus.postStatus("off") VpnEventBus.postStatus("off")
Log.i(TAG, "doStop: done")
} }
// ── PlatformInterfaceinner class,持有 VpnService.Builder 访问权)──── // ══════════════════════════════════════════════════════════════
// PlatformInterface 实现(1.13.12 共 15 法)
// ══════════════════════════════════════════════════════════════
/** override fun openTun(options: TunOptions): Int {
* sing-box libbox 平台回调实现。 if (prepare(this) != null) throw Exception("android: missing vpn permission")
*
* [openTun]: 用 TunOptions 配置 VpnService.Builder,调用 establish() 取 TUN fd。
* [autoDetectInterfaceControl]: 调用 VpnService.protect(fd) 防止 TUN 流量形成环路。
*/
private inner class PangolinPlatformInterface : PlatformInterface {
/** val builder = Builder()
* libbox 回调 — 创建 TUN 接口并返回 fd。 .setSession("Pangolin")
* .setMtu(options.getMTU())
* 注:TunOptions 的具体 getter 方法名(inet4Address / mtu 等)
* 需与实际 libbox.aar 中 go.libbox.TunOptions 的 javadoc 对齐。
* 若方法不存在,请使用 options 对应的实际方法名替换。
*/
override fun openTun(options: TunOptions): Int {
val builder = Builder() // VpnService.Builderinner class,此处可直接调用)
// ── 地址配置 ────────────────────────────────────────── if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) builder.setMetered(false)
// libbox API: TunOptions.inet4Address() 返回 CIDR 字符串(如 "172.19.0.1/30"
// 若 API 名不同(如 getInet4Address())请对应修改 // 地址(RoutePrefixIterator
val inet4 = tryGetInet4(options) val inet4 = options.inet4Address
if (inet4.isNotEmpty()) { while (inet4.hasNext()) {
val slash = inet4.indexOf('/') val a = inet4.next(); builder.addAddress(a.address(), a.prefix())
if (slash > 0) { }
builder.addAddress(inet4.substring(0, slash), inet4.substring(slash + 1).toInt()) val inet6 = options.inet6Address
} else { while (inet6.hasNext()) {
builder.addAddress(inet4, 30) val a = inet6.next(); builder.addAddress(a.address(), a.prefix())
}
if (options.autoRoute) {
// DNS1.13.12:单个 StringBox
try {
val dns = options.getDNSServerAddress()?.getValue()
if (!dns.isNullOrEmpty()) builder.addDnsServer(dns)
} catch (e: Exception) {
Log.w(TAG, "addDnsServer: $e")
}
// 路由:优先 inet4RouteAddress;为空则全量 0.0.0.0/0。
val r4 = options.inet4RouteAddress
if (r4.hasNext()) {
while (r4.hasNext()) { val a = r4.next(); builder.addRoute(a.address(), a.prefix()) }
} else if (options.inet4Address.hasNext()) {
builder.addRoute("0.0.0.0", 0)
}
val r6 = options.inet6RouteAddress
if (r6.hasNext()) {
while (r6.hasNext()) { val a = r6.next(); builder.addRoute(a.address(), a.prefix()) }
} else if (options.inet6Address.hasNext()) {
builder.addRoute("::", 0)
}
// 分应用(include/exclude package
val inc = options.includePackage
while (inc.hasNext()) {
try { builder.addAllowedApplication(inc.next()) } catch (e: Exception) { Log.w(TAG, "allow app: $e") }
}
val exc = options.excludePackage
while (exc.hasNext()) {
try { builder.addDisallowedApplication(exc.next()) } catch (e: Exception) { Log.w(TAG, "disallow app: $e") }
}
}
val pfd = builder.establish() ?: throw Exception("establish() returned null — 未授权或已被撤销")
tunPfd = pfd
Log.i(TAG, "openTun: fd=${pfd.fd}, mtu=${options.mtu}")
return pfd.fd
}
override fun autoDetectInterfaceControl(fd: Int) {
if (!protect(fd)) throw Exception("VpnService.protect(fd=$fd) failed")
}
override fun usePlatformAutoDetectInterfaceControl(): Boolean = true
override fun useProcFS(): Boolean = Build.VERSION.SDK_INT < Build.VERSION_CODES.Q
override fun startDefaultInterfaceMonitor(listener: InterfaceUpdateListener) {
DefaultNetworkMonitor.setListener(listener)
}
override fun closeDefaultInterfaceMonitor(listener: InterfaceUpdateListener) {
DefaultNetworkMonitor.setListener(null)
}
override fun getInterfaces(): NetworkInterfaceIterator {
val list = mutableListOf<libbox.NetworkInterface>()
try {
for (ni in Collections.list(java.net.NetworkInterface.getNetworkInterfaces())) {
val box = libbox.NetworkInterface()
box.name = ni.name
box.index = ni.index
runCatching { box.setMTU(ni.mtu) }
box.addresses = StringArray(
ni.interfaceAddresses.mapNotNull { ia ->
// 砍掉 IPv6 link-local 的 zone 标识(如 %rmnet_data0)——
// sing-box netip.ParsePrefix 不接受 prefix 内含 zone,否则 panic。
val host = ia.address?.hostAddress?.substringBefore('%') ?: return@mapNotNull null
"$host/${ia.networkPrefixLength}"
}.iterator()
)
var flags = 0
runCatching {
if (ni.isUp) flags = flags or OsConstants.IFF_UP or OsConstants.IFF_RUNNING
if (ni.isLoopback) flags = flags or OsConstants.IFF_LOOPBACK
if (ni.isPointToPoint) flags = flags or OsConstants.IFF_POINTOPOINT
if (ni.supportsMulticast()) flags = flags or OsConstants.IFF_MULTICAST
} }
} else { box.flags = flags
// 兜底:使用 PoC 静态配置的地址 box.type = Libbox.InterfaceTypeOther
builder.addAddress("172.19.0.1", 30) list.add(box)
} }
val inet6 = tryGetInet6(options)
if (inet6.isNotEmpty()) {
val slash = inet6.indexOf('/')
if (slash > 0) {
builder.addAddress(inet6.substring(0, slash), inet6.substring(slash + 1).toInt())
}
}
// ── MTU ────────────────────────────────────────────────
val mtu = tryGetMtu(options)
builder.setMtu(mtu)
// ── 路由:将所有流量导入 TUNsing-box auto_route = true)────
builder.addRoute("0.0.0.0", 0) // IPv4 全部流量
builder.addRoute("::", 0) // IPv6 全部流量
// ── DNSsing-box 内置 Fake-IP 地址 ───────────────────
// sing-box 在 172.18.0.0/15 运行 Fake-IP,设置匹配 DNS 服务器
builder.addDnsServer("198.18.0.2")
builder.setSession("Pangolin")
builder.setBlocking(true)
val pfd = builder.establish()
?: throw Exception("VpnService.Builder.establish() returned null — 可能未授权")
currentTunPfd = pfd
Log.i(TAG, "openTun: fd=${pfd.fd}, inet4=$inet4, mtu=$mtu")
return pfd.fd
}
/**
* libbox 回调 — 调用 VpnService.protect() 使 fd 跳过 TUN,防止路由环路。
*
* 注:gomobile 将 Go `error` 返回翻译为抛出 Exception(无返回值)。
*/
override fun autoDetectInterfaceControl(fd: Int) {
if (!protect(fd)) {
throw Exception("VpnService.protect(fd=$fd) failed")
}
}
override fun writeLog(message: String) {
Log.v("libbox", message)
}
/** 使用 Android VpnService 的 protect() 机制,不需要平台独立的路由探测 */
override fun usePlatformAutoDetectInterfaceControl(): Boolean = true
/** 使用 Android 系统的网络变化监听,不使用 libbox 内置监听 */
override fun usePlatformDefaultInterfaceMonitor(): Boolean = false
/** 不使用 libbox 内置接口枚举 */
override fun usePlatformInterfaceGetter(): Boolean = false
/**
* 进程信息查询(用于 sing-box 的应用规则)。
* PoC 阶段不需要,返回 null 即可(libbox 会跳过进程匹配规则)。
*/
override fun findProcessInfo(
networkType: Int,
srcIP: String,
srcPort: Int,
destIP: String,
destPort: Int,
): ProcessInfo? = null
// ── TunOptions 安全 getter(兜底处理 API 名不匹配问题)──
/** 安全读取 inet4Address,若 API 不匹配返回空串,由调用方用兜底值 */
private fun tryGetInet4(options: TunOptions): String = try {
// libbox API: options.inet4Address() — 若名称不同请修改
options.inet4Address()
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "TunOptions.inet4Address() failed: $e") Log.w(TAG, "getInterfaces: $e")
""
} }
return InterfaceArray(list.iterator())
}
private fun tryGetInet6(options: TunOptions): String = try { override fun findConnectionOwner(
options.inet6Address() ipProtocol: Int, sourceAddress: String, sourcePort: Int,
} catch (e: Exception) { destinationAddress: String, destinationPort: Int,
"" ): ConnectionOwner {
} // 我们的配置不含进程匹配规则,核心不会调用;保守抛出。
throw Exception("findConnectionOwner not supported")
}
private fun tryGetMtu(options: TunOptions): Int = try { override fun includeAllNetworks(): Boolean = false
// libbox API: options.mtu() returns Int32/Int
options.mtu().toInt().coerceIn(576, 65535) override fun clearDNSCache() {}
} catch (e: Exception) {
9000 // 兜底:与 PoC 配置对齐 override fun readWIFIState(): WIFIState? = null
}
override fun localDNSTransport(): LocalDNSTransport? = null
override fun systemCertificates(): StringIterator = StringArray(emptyList<String>().iterator())
override fun underNetworkExtension(): Boolean = false
override fun sendNotification(notification: LibboxNotification) {
Log.d(TAG, "libbox notification: ${notification.title} / ${notification.body}")
}
// ══════════════════════════════════════════════════════════════
// CommandServerHandler 实现(5 法)
// ══════════════════════════════════════════════════════════════
override fun serviceReload() { Log.d(TAG, "serviceReload") }
override fun serviceStop() { doStop() }
override fun getSystemProxyStatus(): SystemProxyStatus = SystemProxyStatus().apply {
available = false
enabled = false
}
override fun setSystemProxyEnabled(isEnabled: Boolean) {}
override fun writeDebugMessage(message: String?) { Log.d("sing-box", message ?: "") }
// ── gomobile 迭代器适配 ───────────────────────────────────────
private class StringArray(private val it: Iterator<String>) : StringIterator {
override fun len(): Int = 0 // 内核不依赖精确长度
override fun hasNext(): Boolean = it.hasNext()
override fun next(): String = it.next()
}
private class InterfaceArray(
private val it: Iterator<libbox.NetworkInterface>
) : NetworkInterfaceIterator {
override fun hasNext(): Boolean = it.hasNext()
override fun next(): libbox.NetworkInterface = it.next()
} }
// ── 通知 ───────────────────────────────────────────────────── // ── 通知 ─────────────────────────────────────────────────────
@@ -462,46 +475,36 @@ class PangolinVpnService : VpnService() {
private fun createNotificationChannel() { private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel( val channel = NotificationChannel(
NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_ID, "穿山甲加速", NotificationManager.IMPORTANCE_LOW
"穿山甲加速",
NotificationManager.IMPORTANCE_LOW
).apply { ).apply {
description = "穿山甲加速状态" description = "穿山甲加速状态"
setShowBadge(false) setShowBadge(false)
} }
getSystemService(NotificationManager::class.java) getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
.createNotificationChannel(channel)
} }
} }
private fun updateNotification(contentText: String) { private fun updateNotification(text: String) {
getSystemService(NotificationManager::class.java) getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, buildNotification(text))
.notify(NOTIFICATION_ID, buildNotification(contentText))
} }
private fun buildNotification(contentText: String): Notification { private fun buildNotification(contentText: String): Notification {
val pendingFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val pendingFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
} else { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION") PendingIntent.FLAG_UPDATE_CURRENT
PendingIntent.FLAG_UPDATE_CURRENT
} }
val openIntent = PendingIntent.getActivity( val openIntent = PendingIntent.getActivity(
this, 0, this, 0, Intent(this, MainActivity::class.java), pendingFlags
Intent(this, MainActivity::class.java),
pendingFlags
) )
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(this, NOTIFICATION_CHANNEL_ID) Notification.Builder(this, NOTIFICATION_CHANNEL_ID)
} else { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION") Notification.Builder(this)
Notification.Builder(this)
} }
return builder return builder
.setContentTitle("穿山甲") .setContentTitle("穿山甲")
.setContentText(contentText) // "加速已开启" / "正在连接…" / "连接失败" .setContentText(contentText)
.setSmallIcon(android.R.drawable.ic_dialog_info) .setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentIntent(openIntent) .setContentIntent(openIntent)
.setOngoing(true) .setOngoing(true)
@@ -0,0 +1,20 @@
<!-- 自适应图标背景层:满幅竖向渐变(对齐 app-icon.svg 的 #C8794A→#9E5630)。
圆角由启动器遮罩负责,背景不做圆角。 -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M0,0L108,0L108,108L0,108Z">
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="54" android:startY="0"
android:endX="54" android:endY="108">
<item android:offset="0.0" android:color="#FFC8794A" />
<item android:offset="1.0" android:color="#FF9E5630" />
</gradient>
</aapt:attr>
</path>
</vector>
@@ -0,0 +1,46 @@
<!-- 自适应图标前景层:穿山甲 mark(取自 logo-mark-white.svg)。
外层 group 把 96 viewBox 缩到 0.8 并居中到 108 画布的安全区(避免被遮罩裁到腿/尾);
内层 group 复刻 SVG 的水平镜像 transform="translate(96,0) scale(-1,1)"。 -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:translateX="15.6"
android:translateY="15.6"
android:scaleX="0.8"
android:scaleY="0.8">
<group
android:translateX="96"
android:scaleX="-1">
<!-- 身体 -->
<path
android:fillColor="#F4EFE8"
android:pathData="M16,59 Q17,51 26,50 Q34,31 45,30 Q55,30 62,37 Q69,41 75,45 Q85,49 89,44 Q92,50 84,52 Q75,53 67,53 Q66,62 60,62 L56,62 Q54,55 49,55 Q47,62 37,62 L33,62 Q31,56 26,56 Q20,59 16,59 Z" />
<!-- 鳞纹 -->
<path
android:strokeColor="#9E5630"
android:strokeWidth="2.4"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M26,50 Q34,43 42,50" />
<path
android:strokeColor="#9E5630"
android:strokeWidth="2.4"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M36,48 Q44,41 52,48" />
<path
android:strokeColor="#9E5630"
android:strokeWidth="2.4"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M46,48 Q54,41 62,49" />
<!-- 眼睛 -->
<path
android:fillColor="#9E5630"
android:pathData="M20.7,55 a2.3,2.3 0 1,0 4.6,0 a2.3,2.3 0 1,0 -4.6,0 Z" />
</group>
</group>
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 自适应图标(API 26+):橙色渐变背景 + 穿山甲前景,启动器按设备形状统一遮罩。 -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,28 @@
<!-- <API26 回退:满幅渐变 + 穿山甲(无自适应遮罩)。API26+ 用 mipmap-anydpi-v26 自适应版。 -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="96dp"
android:height="96dp"
android:viewportWidth="96"
android:viewportHeight="96">
<path android:pathData="M0,0L96,0L96,96L0,96Z">
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="48" android:startY="0"
android:endX="48" android:endY="96">
<item android:offset="0.0" android:color="#FFC8794A" />
<item android:offset="1.0" android:color="#FF9E5630" />
</gradient>
</aapt:attr>
</path>
<group android:translateX="96" android:scaleX="-1">
<path
android:fillColor="#F4EFE8"
android:pathData="M16,59 Q17,51 26,50 Q34,31 45,30 Q55,30 62,37 Q69,41 75,45 Q85,49 89,44 Q92,50 84,52 Q75,53 67,53 Q66,62 60,62 L56,62 Q54,55 49,55 Q47,62 37,62 L33,62 Q31,56 26,56 Q20,59 16,59 Z" />
<path android:strokeColor="#9E5630" android:strokeWidth="2.4" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M26,50 Q34,43 42,50" />
<path android:strokeColor="#9E5630" android:strokeWidth="2.4" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M36,48 Q44,41 52,48" />
<path android:strokeColor="#9E5630" android:strokeWidth="2.4" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData="M46,48 Q54,41 62,49" />
<path android:fillColor="#9E5630" android:pathData="M20.7,55 a2.3,2.3 0 1,0 4.6,0 a2.3,2.3 0 1,0 -4.6,0 Z" />
</group>
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 自适应图标背景色(品牌 clay 色,对齐 design token accent -->
<color name="ic_launcher_background">#E0884F</color>
</resources>
+2 -15
View File
@@ -1,16 +1,3 @@
buildscript {
ext.kotlin_version = '1.9.0'
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.1.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
allprojects { allprojects {
repositories { repositories {
google() google()
@@ -18,12 +5,12 @@ allprojects {
} }
} }
rootProject.buildDir = '../build' rootProject.buildDir = "../build"
subprojects { subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}" project.buildDir = "${rootProject.buildDir}/${project.name}"
} }
subprojects { subprojects {
project.evaluationDependsOn(':app') project.evaluationDependsOn(":app")
} }
tasks.register("clean", Delete) { tasks.register("clean", Delete) {
+4
View File
@@ -1,3 +1,7 @@
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true android.useAndroidX=true
android.enableJetifier=true android.enableJetifier=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
+22 -8
View File
@@ -1,11 +1,25 @@
include ':app' pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}()
def localPropertiesFile = new File(rootProject.projectDir, "local.properties") includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
def properties = new Properties()
assert localPropertiesFile.exists() repositories {
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } google()
mavenCentral()
gradlePluginPortal()
}
}
def flutterSdkPath = properties.getProperty("flutter.sdk") plugins {
assert flutterSdkPath != null, "flutter.sdk not set in local.properties" id "dev.flutter.flutter-plugin-loader" version "1.0.0"
apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" id "com.android.application" version "8.6.0" apply false
id "org.jetbrains.kotlin.android" version "2.2.0" apply false
}
include ":app"
+5 -1
View File
@@ -1,7 +1,8 @@
// vpn_bridge_provider.dart — VpnBridge 平台分派 // vpn_bridge_provider.dart — VpnBridge 平台分派
// //
// macOS / Linux / Windows → DesktopVpnBridge(真实子进程) // macOS / Linux / Windows → DesktopVpnBridge(真实子进程)
// 其他平台(iOS / Web / 测试)→ VpnBridgeMock // Android / iOS → VpnNativeBridge(内嵌 libbox,走 pangolin/vpn 通道)
// 其他平台(Web / 测试)→ VpnBridgeMock
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -24,6 +25,9 @@ final vpnBridgeProvider = Provider<VpnBridge>((ref) {
if (Platform.isMacOS && kUseNativeVpnMacOS) { if (Platform.isMacOS && kUseNativeVpnMacOS) {
return VpnNativeBridge(); return VpnNativeBridge();
} }
if (Platform.isAndroid) {
return VpnNativeBridge();
}
// iOS:NEPacketTunnelProvider 内嵌 libbox(B1/C1 已打通)。走原生 MethodChannel // iOS:NEPacketTunnelProvider 内嵌 libbox(B1/C1 已打通)。走原生 MethodChannel
// (pangolin/vpn → AppDelegate → VpnManager),不再用 mock。 // (pangolin/vpn → AppDelegate → VpnManager),不再用 mock。
if (Platform.isIOS) { if (Platform.isIOS) {
+170
View File
@@ -0,0 +1,170 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pangolin Android 客户端设计方案</title>
<style>
:root{
--bg:#0f1117; --panel:#171a22; --panel2:#1d2129; --fg:#e6e8ee; --fg2:#a8afbd;
--accent:#e0884f; --accent2:#5fb0c9; --ok:#5ec27a; --bad:#e06a6a; --warn:#e0b84f;
--border:#272c36; --mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
.wrap{max-width:920px;margin:0 auto;padding:48px 24px 96px}
h1{font-size:30px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em}
.sub{color:var(--fg2);font-size:15px;margin:0 0 32px}
h2{font-size:21px;margin:48px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border)}
h3{font-size:16px;margin:28px 0 8px;color:var(--accent2)}
p{margin:10px 0}
code{font-family:var(--mono);font-size:.88em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
pre{background:#0a0c11;border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;font-family:var(--mono);font-size:13px;line-height:1.55;color:#cdd3df}
pre .c{color:#6b7385}
pre .r{color:var(--bad)}
pre .g{color:var(--ok)}
pre .y{color:var(--warn)}
.tag{display:inline-block;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px;vertical-align:middle}
.tag.bad{background:rgba(224,106,106,.16);color:var(--bad)}
.tag.ok{background:rgba(94,194,122,.16);color:var(--ok)}
.tag.warn{background:rgba(224,184,79,.16);color:var(--warn)}
.tag.info{background:rgba(95,176,201,.16);color:var(--accent2)}
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:16px 0}
.card.root{border-left:3px solid var(--accent)}
.card h3{margin-top:0}
table{width:100%;border-collapse:collapse;margin:16px 0;font-size:14px}
th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:top}
th{color:var(--fg2);font-weight:600;font-size:13px}
td code{font-size:.85em}
.ok-c{color:var(--ok)} .bad-c{color:var(--bad)} .warn-c{color:var(--warn)}
ul,ol{padding-left:22px;margin:10px 0}
li{margin:5px 0}
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 8px}
.kbd{font-family:var(--mono);font-size:.85em;color:var(--accent)}
.small{color:var(--fg2);font-size:13px}
hr{border:none;border-top:1px solid var(--border);margin:40px 0}
a{color:var(--accent2)}
.back{display:inline-block;margin-bottom:24px;font-size:13px}
</style>
</head>
<body>
<div class="wrap">
<a class="back" href="index.html">← 文档索引</a>
<h1>Pangolin Android 客户端设计方案</h1>
<p class="sub">目标终点:<b>MVP 端到端连通 + 切节点 + KillSwitch(清掉 TODO 11G</b> · 分支 <code>feature/android</code> · 2026-06-22</p>
<div class="lead">
<strong>一句话定性:</strong><b>不是从零开发</b>,而是把已有的 <b>M2 纸面 PoC 推到真机能连真实节点出网</b>,并补齐切节点 + KillSwitch(清掉 TODO 11G)。Flutter UI 全平台共享、本次零改动。
</div>
<h2>1. 现状盘点</h2>
<p>之前的任务(commit <code>83c23f9</code> "feat(android): 实现 M2 Android VPN 隧道")已写好相当完整的原生层,但<b>从未真正编译运行过</b></p>
<div class="card">
<h3>已具备 <span class="tag ok">现成</span></h3>
<ul>
<li>Flutter UI 全平台共享(连接键三态 / 节点 / 统计 / 账户都现成,Android 直接复用)。</li>
<li>Channel 契约已冻结(<code>pangolin/vpn</code> MethodChannel + <code>pangolin/vpn/status</code><code>pangolin/vpn/stats</code> EventChannel),见 <code>client/lib/bridge/vpn_bridge.dart</code></li>
<li><code>MainActivity.kt</code> —— 通道注册 + VPN 授权流程(<code>VpnService.prepare</code>+ 电池优化豁免引导。</li>
<li><code>PangolinVpnService.kt</code> —— <code>VpnService</code> + libbox <code>BoxService</code> 集成、<code>openTun</code>、统计(CommandClient + TrafficStats 兜底)、前台通知。</li>
<li><code>VpnEventBus.kt</code> —— Service↔Activity 状态总线(主线程回调)。</li>
<li><code>AndroidManifest.xml</code> —— VPN 权限、前台服务 <code>specialUse</code>(适配 Android 14)。</li>
<li><code>build-android.sh</code> —— gomobile 编译 sing-box → <code>libbox.aar</code>sing-box <code>v1.13.12</code> / Go <code>1.24.3</code> / gomobile pin <code>v0.0.0-20240604…</code>tag <code>with_quic,with_utls,with_clash_api,with_gvisor</code>)。</li>
<li><code>poc_config.json</code> —— REALITY 配置模板。</li>
</ul>
</div>
<div class="card">
<h3>缺口(让它真正能跑要补的)<span class="tag bad">待补</span></h3>
<ol>
<li><b><code>libbox.aar</code> 没构建过</b> —— <code>app/kernel/dist/android/</code> 为空。需要 Android NDK + gomobile(硬前置)。</li>
<li><b>原生 libbox API 名是「猜」的</b> —— 代码里多处注释「若编译失败请对照实际 libbox API 微调」(<code>Libbox.newBoxService</code><code>TunOptions.inet4Address()</code> 等从没被真实 aar 校验过)。</li>
<li><b>Dart 侧没接线</b> —— <code>vpn_bridge_provider.dart</code> 目前把 Android 落到 <code>VpnBridgeMock</code>(只处理了 macOS/桌面)。</li>
<li><b>三个 stub</b> —— <code>selectOutbound</code> / <code>getActiveOutbound</code> / <code>setKillSwitch</code> 标了 TODO 11G。</li>
<li><b>从没在真机/模拟器上端到端连过。</b></li>
</ol>
</div>
<h2>2. 整体策略:自底向上「先构建,再对着真实 API 修」</h2>
<p>最大未知是 <b>libbox.aar 能不能构建出来、原生代码里那些「猜」的 API 名对不对</b>。所以第一步就把这个雷踩掉,而不是最后才发现编译不过。</p>
<p><b>正确性锚点</b>:官方 <code>SagerNet/sing-box-for-android</code>SFA)的 Kotlin 源 + 构建出的 aar 里 <code>classes.jar</code> 的真实方法签名。现有 <code>PangolinVpnService.kt</code> 结构是照 SFA 写的,只是 API 名没校验过——拿真 aar 一比对即可定。</p>
<h2>3. 里程碑(垂直切片,每片真机可验)</h2>
<table>
<thead><tr><th>里程碑</th><th>目标</th><th>验收</th></tr></thead>
<tbody>
<tr><td><b>A. 构建内核</b></td><td>跑通 <code>build-android.sh</code> 产出 <code>libbox.aar</code>arm64/arm/amd64tag 含 <code>with_clash_api</code></td><td>aar 存在、<code>unzip -l</code> 三 ABI 的 <code>.so</code></td></tr>
<tr><td><b>B. 编译链接</b></td><td>Dart provider 接 Android→<code>VpnNativeBridge</code>;原生代码对着真 aar 修到 <code>flutter build apk</code> 通过</td><td>APK 构建成功、<code>flutter analyze</code> 零警告</td></tr>
<tr><td><b>C. 端到端连通</b></td><td>真机一键连 RackNerd 节点,TUN 起、DNS 不劫持失败、能打开被墙站点</td><td>真机实测科学上网成功;UI 三态正确(连接键 off→connecting→on <b>严格由内核回调驱动,禁止乐观显示</b></td></tr>
<tr><td><b>D. 统计走字</b></td><td>stats EventChannel 每秒推上/下行字节 + 速率</td><td>统计页数字跳动,与系统流量大致吻合</td></tr>
<tr><td><b>E. 切节点(11G</b></td><td><code>selectOutbound(tag)</code> 经 libbox CommandClient 做 group 选择(复刻桌面 <code>selectProxy</code>);<code>getActiveOutbound</code> 查询当前出口</td><td>节点页切换出口不断连、当前出口高亮正确</td></tr>
<tr><td><b>F. KillSwitch11G</b></td><td><code>setKillSwitch(on)</code><b>L1<code>strict_route</code>+ 引导系统 Always-onL3</b></td><td>开关后内核停止即断网/恢复符合预期;UI 诚实标注「彻底防泄漏需到系统设置开启」</td></tr>
</tbody>
</table>
<h2>4. 关键技术决策(与桌面/契约对齐,不自创)</h2>
<ul>
<li><b>配置来源不变</b>Dart 侧 <code>connect_api.fetchConfig</code> 从服务端 <code>POST /v1/nodes/:id/connect</code><b>完整 sing-box config</b>,原样传 <code>bridge.start(configJson)</code><b>客户端绝不拼配置。</b></li>
<li><b>TUN 由 <code>PlatformInterface.openTun</code></b>libbox 回调里用 <code>VpnService.Builder</code> 配地址/MTU/路由/DNS → <code>establish()</code> 拿 fd。<code>autoDetectInterfaceControl</code><code>VpnService.protect()</code> 防环路。现有代码已写好,重点是<b>校验 <code>TunOptions</code> 的真实 getter 名</b>(已用 try-catch 兜底)。</li>
<li><b>DNS 劫持铁律</b>:和 macOS 一样,服务端 config 的 <code>route.rules</code> 首条必须是 <code>{"action":"hijack-dns","port":[53]}</code>——否则隧道连上也打不开网站。这是服务端职责,Android 侧只需确认下发的 config 带这条。</li>
<li><b>统计</b>:优先 libbox <code>CommandClient</code>command=STATUS, 1s);连不上退回 <code>TrafficStats</code>(按 UID)。现有代码已实现双路。</li>
<li><b>切节点 = Clash group 选择</b>:复刻桌面 <code>clashApiClient.selectProxy(group, tag)</code> 的语义,Android 用 libbox <code>CommandClient</code> 的 group 选择 API。组名对着服务端渲染的 config 确认(config 含 <code>auto</code> urltest 组 + <code>reality-out</code>/<code>hy2-out</code> 出口)。</li>
<li><b>KillSwitch = <code>strict_route</code> + 引导 Always-on</b>:详见 §5 第 1 条与 <a href="killswitch-design.html">KillSwitch 设计知识库</a></li>
</ul>
<h2>5. 三处定调(用户已确认)</h2>
<div class="card root">
<h3>① KillSwitch(里程碑 F</h3>
<p>做「L1 + 引导 L3」的组合——<code>setKillSwitch(on)</code><code>strict_route</code>(与 Windows 桌面统一)+ 在 UI 诚实标注「彻底防泄漏需到系统设置开 Always-on」,并提供跳转引导。<b>不假装 app 内能做到真 KillSwitch。</b> 背景与分级见 <a href="killswitch-design.html">KillSwitch 设计知识库</a></p>
</div>
<div class="card root">
<h3>② 工具链(构建后修正)</h3>
<p>原定「降 Go 保 pin」——但实测 pin 失效,真实情况:用 <code>golang.org/dl/go1.24.3</code> 当启动器(go.mod 为 <code>go 1.24.7</code>,自动切 1.24.7 编译);gomobile 改用<b>官方 SagerNet fork <code>github.com/sagernet/gomobile@v0.1.12</code></b>(非上游,对照 sing-box go.mod 确认);bind 加 <code>-checklinkname=0</code> + tags <code>badlinkname,tfogo_checklinkname0</code>JDK 用 Android Studio JBR;国内 <code>GOPROXY=goproxy.cn</code>。Android 栈升 <b>Gradle 8.7 + AGP 8.6 + Kotlin 2.2.0 + 声明式插件</b>。详见实现计划。</p>
</div>
<div class="card root">
<h3>③ 测试基线(里程碑 C</h3>
<p><b>先 x86_64 模拟器跑通</b>(环境最干净、最贴官方验证、排除真机变量)→ <b>Vivo X200Android 16 / API 36AOSP 系,主力真机)</b><b>华为 HarmonyOS 4.2(兼容 Android 版,次要兼容性抽查)</b>。华为后台保活激进、电池优化引导 Intent 需单独适配。</p>
</div>
<h2>6. 已知风险 / 坑</h2>
<ol>
<li><b>Go 1.26 vs gomobile 旧 pin</b>:若 <code>gomobile bind</code> 失败,按定调降 Go 1.24.3(保 pin)。</li>
<li><b>当前没有设备连着</b><code>adb devices</code> 为空)——里程碑 C 起需插真机/开模拟器。模拟器为 x86_64,aar 必须含 amd64(脚本已含)。</li>
<li><b>libbox API 漂移</b>:现有 <code>.kt</code> 方法名是猜的,里程碑 B 逐一对平(TunOptions getter 已 try-catch 兜底)。</li>
<li><b>Android 14/16 前台服务 specialUse</b>manifest 已声明;targetSdk 拉到 36 时复测 FGS 启动是否被限。国内分发免审;未来上 Play 需补用途说明。</li>
<li><b>电池优化 / 厂商保活</b>:现有代码会弹豁免引导;华为/Vivo 后台管理激进,需引导用户允许后台 + 自启动,且厂商设置页深链可能落不准。</li>
</ol>
<h2>7. 不在本次范围(YAGNI</h2>
<ul>
<li>国内分流 <code>.srs</code> 本地预取(现走远程 rule-set)。</li>
<li>应用图标 / 启动页打磨。</li>
<li>签名打包 / 应用市场上架 / 侧载分发流程。</li>
<li>per-app 分应用代理。</li>
<li>macOS 原生侧的 11G<code>selectOutbound</code>/<code>getActiveOutbound</code>/<code>setKillSwitch</code>)补齐——平行未完成,见 <a href="killswitch-design.html">KillSwitch 知识库</a> 与 todo #1,不在本次范围。</li>
</ul>
<h2>8. 参考位置索引</h2>
<table>
<thead><tr><th>主题</th><th>文件</th></tr></thead>
<tbody>
<tr><td>桥接契约</td><td><code>client/lib/bridge/vpn_bridge.dart</code></td></tr>
<tr><td>平台分派(需加 Android→Native</td><td><code>client/lib/bridge/vpn_bridge_provider.dart</code></td></tr>
<tr><td>连接状态机(调 bridge.start</td><td><code>client/lib/state/connection_provider.dart</code></td></tr>
<tr><td>取 config</td><td><code>client/lib/services/connect_api.dart</code></td></tr>
<tr><td>Android 通道注册</td><td><code>client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/MainActivity.kt</code></td></tr>
<tr><td>Android VPN 服务</td><td><code>client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/PangolinVpnService.kt</code></td></tr>
<tr><td>内核构建脚本</td><td><code>app/kernel/build-android.sh</code></td></tr>
<tr><td>内核版本锚点</td><td><code>app/kernel/VERSION</code></td></tr>
<tr><td>桌面参考实现(selectOutbound/killSwitch</td><td><code>client/lib/bridge/desktop_vpn_bridge.dart</code></td></tr>
<tr><td>服务端 config 渲染</td><td><code>server/internal/httpapi/clientconfig.go</code></td></tr>
<tr><td>KillSwitch 设计知识库</td><td><a href="killswitch-design.html">docs/killswitch-design.html</a></td></tr>
</tbody>
</table>
</div>
</body>
</html>
+92 -70
View File
@@ -16,93 +16,115 @@
.wrap{max-width:920px;margin:0 auto;padding:48px 24px 96px} .wrap{max-width:920px;margin:0 auto;padding:48px 24px 96px}
h1{font-size:30px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em} h1{font-size:30px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em}
.sub{color:var(--fg2);font-size:15px;margin:0 0 32px} .sub{color:var(--fg2);font-size:15px;margin:0 0 32px}
h2{font-size:21px;margin:44px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border)} h2{font-size:21px;margin:40px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border)}
p{margin:10px 0}
code{font-family:var(--mono);font-size:.88em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
a{color:var(--accent2);text-decoration:none} a{color:var(--accent2);text-decoration:none}
a:hover{text-decoration:underline} a:hover{text-decoration:underline}
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:6px 20px;margin:16px 0}
ul{padding-left:0;margin:10px 0;list-style:none}
li{margin:12px 0;padding-left:0}
.doc-title{font-size:15px;font-weight:600}
.desc{color:var(--fg2);font-size:13px;margin:2px 0 0}
.tag{display:inline-block;font-size:11px;font-weight:600;padding:1px 8px;border-radius:999px;vertical-align:middle;margin-left:6px}
.tag.html{background:rgba(95,176,201,.16);color:var(--accent2)}
.tag.md{background:rgba(168,175,189,.14);color:var(--fg2)}
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 8px} .lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 8px}
.small{color:var(--fg2);font-size:13px}
.tag{display:inline-block;font-size:11px;font-weight:700;padding:1px 8px;border-radius:999px;vertical-align:middle;margin-left:6px}
.tag.html{background:rgba(94,194,122,.16);color:var(--ok)}
.tag.md{background:rgba(168,175,189,.16);color:var(--fg2)}
.doc{display:block;background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:14px 16px;margin:10px 0;transition:border-color .15s}
.doc:hover{border-color:var(--accent2);text-decoration:none}
.doc .t{font-size:16px;font-weight:600;color:var(--fg)}
.doc .d{color:var(--fg2);font-size:13px;margin-top:3px}
.doc .path{font-family:var(--mono);font-size:11px;color:#6b7385;margin-top:4px}
</style> </style>
</head> </head>
<body> <body>
<div class="wrap"> <div class="wrap">
<h1>Pangolin 文档索引</h1> <h1>Pangolin 文档索引</h1>
<p class="sub">单一入口:本项目所有设计 / 计划 / 调研 / 排障文档汇总。新增文档须登记于此。</p> <p class="sub">全部设计 / 调研 / 排障文档汇总 · 单一入口</p>
<div class="lead"> <div class="lead">
<p style="margin:0">家族规范:设计/调研类新文档一律 <strong>HTML</strong>(深色主题、自包含);README / plan / CLAUDE.md / 代码内文档仍按既有形式。历史 <code>.md</code> 保留原状、逐步迁移。</p> <strong>约定:</strong>新的<b>设计文档 / 调研文档</b>统一以 <b>HTML</b> 形式输出(深色家族风格,可直接 <code>file://</code> 打开),并登记到本索引。历史 <code>.md</code> 文档保留原状、逐步迁移。
</div> </div>
<h2>设计方案</h2> <h2>设计方案 / Specs</h2>
<div class="card"> <a class="doc" href="android-client-design.html">
<ul> <div class="t">Pangolin Android 客户端设计方案 <span class="tag html">HTML</span></div>
<li> <div class="d">把 M2 纸面 PoC 推到真机端到端连通 + 切节点 + KillSwitch(清 TODO 11G)。里程碑 A–F、三处定调。</div>
<a class="doc-title" href="ios-ipad-support-design.html">iOS + iPad 支持设计方案</a><span class="tag html">HTML</span> <div class="path">docs/android-client-design.html</div>
<p class="desc">iOS 隧道从 PoC 桩推到真机连通 + TestFlight;五条工作线 + NE 50MB 内存闸门;iPad 布局适配(分支 <code>feature/pangolin-ios</code></p> </a>
</li> <a class="doc" href="ios-ipad-support-design.html">
<li> <div class="t">iOS + iPad 支持设计方案 <span class="tag html">HTML</span></div>
<a class="doc-title" href="技术方案.md">技术方案</a><span class="tag md">MD</span> <div class="d">iOS 隧道从 PoC 桩推到真机连通 + TestFlight;五条工作线 + NE 50MB 内存闸门;iPad 横屏侧栏布局适配。</div>
<p class="desc">总体技术选型与架构</p> <div class="path">docs/ios-ipad-support-design.html</div>
</li> </a>
<li> <a class="doc" href="superpowers/specs/2026-06-21-windows-client-design.md">
<a class="doc-title" href="vpn-core-embedding.md">VPN 核心嵌入(方案B</a><span class="tag md">MD</span> <div class="t">Windows 客户端设计 <span class="tag md">MD</span></div>
<p class="desc">嵌入 libbox + 系统 VPN API 替换 sudo sing-box 的完整设计</p> <div class="d">Windows 端 sing-box 子进程 + wintun 隧道客户端设计</div>
</li> <div class="path">docs/superpowers/specs/2026-06-21-windows-client-design.md</div>
</ul> </a>
</div>
<h2>实现计划</h2> <h2>实现计划 / Plans</h2>
<div class="card"> <a class="doc" href="superpowers/plans/2026-06-22-android-client.md">
<ul> <div class="t">Android 客户端实现计划 <span class="tag md">MD</span></div>
<li> <div class="d">11 个任务:Go 1.24.3 工具链 → 构建 libbox.aar → 解析真实 API → Dart 接线 → 修原生编译 → 模拟器端到端 → 统计 → 切节点 → KillSwitch → 真机验证。</div>
<a class="doc-title" href="ios-ipad-support-plan.html">iOS + iPad 支持 实施计划</a><span class="tag html">HTML</span> <div class="path">docs/superpowers/plans/2026-06-22-android-client.md</div>
<p class="desc">阅读版;执行真相源为 <code>docs/superpowers/plans/2026-06-22-ios-ipad-support.md</code>(含 checkbox)。6 阶段 12 任务</p> </a>
</li> <a class="doc" href="ios-ipad-support-plan.html">
<li> <div class="t">iOS + iPad 支持 实现计划 <span class="tag html">HTML</span></div>
<a class="doc-title" href="p1-macos-system-extension.md">P1 macOS 系统扩展</a><span class="tag md">MD</span> <div class="d">阅读版;执行真相源为 <code>docs/superpowers/plans/2026-06-22-ios-ipad-support.md</code>(含 checkbox)。6 阶段 12 任务。</div>
<p class="desc">macOS PacketTunnel 系统扩展实现计划</p> <div class="path">docs/ios-ipad-support-plan.html</div>
</li> </a>
<li> <a class="doc" href="superpowers/plans/2026-06-21-windows-client.md">
<a class="doc-title" href="node-rebuild-checklist.md">节点重建清单</a><span class="tag md">MD</span> <div class="t">Windows 客户端实现计划 <span class="tag md">MD</span></div>
<p class="desc">节点重建步骤清单</p> <div class="d">Windows 客户端的分步实现计划。</div>
</li> <div class="path">docs/superpowers/plans/2026-06-21-windows-client.md</div>
</ul> </a>
</div>
<h2>知识库 / 调研</h2> <h2>知识库 / 调研</h2>
<div class="card"> <a class="doc" href="killswitch-design.html">
<ul> <div class="t">KillSwitch 设计与跨平台能力矩阵 <span class="tag html">HTML</span></div>
<li> <div class="d">断网保护 L0–L3 分级模型 + 各平台能力天花板 / 当前实现矩阵。KillSwitch 决策依据。</div>
<a class="doc-title" href="vpn-testing-research.md">VPN 测试调研</a><span class="tag md">MD</span> <div class="path">docs/killswitch-design.html</div>
<p class="desc">黑盒测试方法调研</p> </a>
</li> <a class="doc" href="技术方案.md">
<li> <div class="t">技术方案(总) <span class="tag md">MD</span></div>
<a class="doc-title" href="vpn-test-plan.md">VPN 测试计划</a><span class="tag md">MD</span> <div class="d">Pangolin 整体技术选型与架构方案。</div>
<p class="desc">国内外站点矩阵 + 延迟口径</p> <div class="path">docs/技术方案.md</div>
</li> </a>
</ul> <a class="doc" href="vpn-core-embedding.md">
</div> <div class="t">VPN 内核内嵌 <span class="tag md">MD</span></div>
<div class="d">sing-box 内核在各端的内嵌方式调研。</div>
<div class="path">docs/vpn-core-embedding.md</div>
</a>
<a class="doc" href="vpn-testing-research.md">
<div class="t">VPN 测试调研 <span class="tag md">MD</span></div>
<div class="d">VPN 黑盒测试方法与口径调研。</div>
<div class="path">docs/vpn-testing-research.md</div>
</a>
<a class="doc" href="vpn-test-plan.md">
<div class="t">VPN 测试计划 <span class="tag md">MD</span></div>
<div class="d">国内外站点矩阵 + 延迟/连通性测试计划。</div>
<div class="path">docs/vpn-test-plan.md</div>
</a>
<h2>排障 Runbook</h2> <h2>排障 / Runbook</h2>
<div class="card"> <a class="doc" href="macos-sysext-realize-troubleshooting.html">
<ul> <div class="t">macOS 系统扩展 realize 失败(code=4)踩坑复盘 <span class="tag html">HTML</span></div>
<li> <div class="d">PacketTunnel 系统扩展无法激活的三个叠加配置 bug 排查与修复。</div>
<a class="doc-title" href="macos-sysext-realize-troubleshooting.html">macOS 系统扩展 realize 失败排障</a><span class="tag html">HTML</span> <div class="path">docs/macos-sysext-realize-troubleshooting.html</div>
<p class="desc"><code>OSSystemExtensionErrorDomain code=4</code> 踩坑复盘 —— 改 macOS 隧道前必读</p> </a>
</li> <a class="doc" href="p1-macos-system-extension.md">
<li> <div class="t">P1 macOS 系统扩展 <span class="tag md">MD</span></div>
<a class="doc-title" href="runbook-scheduler.md">调度器 Runbook</a><span class="tag md">MD</span> <div class="d">macOS 原生 System Extension 隧道方案说明。</div>
<p class="desc">调度器运维手册</p> <div class="path">docs/p1-macos-system-extension.md</div>
</li> </a>
</ul> <a class="doc" href="node-rebuild-checklist.md">
</div> <div class="t">节点重建检查单 <span class="tag md">MD</span></div>
<div class="d">节点重装/重建的操作检查清单。</div>
<div class="path">docs/node-rebuild-checklist.md</div>
</a>
<a class="doc" href="runbook-scheduler.md">
<div class="t">Scheduler Runbook <span class="tag md">MD</span></div>
<div class="d">调度器运维手册。</div>
<div class="path">docs/runbook-scheduler.md</div>
</a>
</div> </div>
</body> </body>
+151
View File
@@ -0,0 +1,151 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KillSwitch 设计与跨平台能力矩阵(知识库)</title>
<style>
:root{
--bg:#0f1117; --panel:#171a22; --panel2:#1d2129; --fg:#e6e8ee; --fg2:#a8afbd;
--accent:#e0884f; --accent2:#5fb0c9; --ok:#5ec27a; --bad:#e06a6a; --warn:#e0b84f;
--border:#272c36; --mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
.wrap{max-width:920px;margin:0 auto;padding:48px 24px 96px}
h1{font-size:30px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em}
.sub{color:var(--fg2);font-size:15px;margin:0 0 32px}
h2{font-size:21px;margin:48px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border)}
h3{font-size:16px;margin:28px 0 8px;color:var(--accent2)}
p{margin:10px 0}
code{font-family:var(--mono);font-size:.88em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
pre{background:#0a0c11;border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;font-family:var(--mono);font-size:13px;line-height:1.55;color:#cdd3df}
.tag{display:inline-block;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px;vertical-align:middle}
.tag.bad{background:rgba(224,106,106,.16);color:var(--bad)}
.tag.ok{background:rgba(94,194,122,.16);color:var(--ok)}
.tag.warn{background:rgba(224,184,79,.16);color:var(--warn)}
.tag.info{background:rgba(95,176,201,.16);color:var(--accent2)}
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:16px 0}
.card.root{border-left:3px solid var(--accent)}
.card h3{margin-top:0}
table{width:100%;border-collapse:collapse;margin:16px 0;font-size:14px}
th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:top}
th{color:var(--fg2);font-weight:600;font-size:13px}
td code{font-size:.85em}
.ok-c{color:var(--ok)} .bad-c{color:var(--bad)} .warn-c{color:var(--warn)}
ul,ol{padding-left:22px;margin:10px 0}
li{margin:5px 0}
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 8px}
.small{color:var(--fg2);font-size:13px}
hr{border:none;border-top:1px solid var(--border);margin:40px 0}
a{color:var(--accent2)}
.back{display:inline-block;margin-bottom:24px;font-size:13px}
.lvl{font-weight:700}
</style>
</head>
<body>
<div class="wrap">
<a class="back" href="index.html">← 文档索引</a>
<h1>KillSwitch 设计与跨平台能力矩阵<span class="small">(知识库)</span></h1>
<p class="sub">Pangolin 客户端 · 断网保护设计依据与现状 · 2026-06-22</p>
<div class="lead">
KillSwitch 不是「一个开关」,而是<b>分层能力</b>——理想态、平台天花板、当前实现是三件不同的事。本文沉淀其设计依据与现状。
</div>
<h2>1. 本质:fail-closed</h2>
<p>KillSwitch 的本质是 <b>fail-closed</b></p>
<div class="card"><b>隧道不处于活动状态时,禁止任何流量走明文/默认路径出去。</b></div>
<p>目标是堵住 VPN 掉线瞬间的 <b>IP / DNS 泄漏</b>。没有 KillSwitch 时,隧道一掉,流量静默回落到运营商默认路由,用户真实 IP 与 DNS 查询直接暴露——对科学上网场景是致命泄漏。</p>
<h2>2. 理想设计的 5 条属性</h2>
<ol>
<li><b>默认关闭(fail-closed</b>:隧道一旦不可用,流量是「被丢弃」而非「放行」。</li>
<li><b>扛得住进程死亡</b> ← 最难。app/内核进程崩溃或被系统杀掉后,封锁<b>依然生效</b>。这要求封锁由 <b>OS 内核/框架</b>强制,而不是靠 app 进程内的路由表。</li>
<li><b>覆盖所有「缺口窗口」</b>:开机后还没连上的窗口、掉线重连的窗口、进程崩溃的窗口——三个都要堵。</li>
<li><b>DNS 防泄漏</b>:DNS 查询也强制走隧道或被一并阻断。</li>
<li><b>可控范围 + 诚实 UI</b>:可选放行 LAN(局域网打印机等)、可选分应用;并<b>如实</b>告诉用户当前到底保护到了哪一层,不夸大。</li>
</ol>
<h2>3. 能力分级(统一标尺)</h2>
<table>
<thead><tr><th>Level</th><th>机制</th><th>能堵什么</th><th>堵不住什么</th></tr></thead>
<tbody>
<tr><td><span class="lvl bad-c">L0 无</span></td><td>掉线即走明文</td><td></td><td>全泄漏</td></tr>
<tr><td><span class="lvl warn-c">L1 内核路由绑定(软)</span></td><td>路由绑死隧道接口(如 sing-box <code>strict_route</code></td><td>进程活着、隧道未建/重连时不泄漏</td><td><b>进程一死、接口被拆 → 路由恢复 → 泄漏</b></td></tr>
<tr><td><span class="lvl ok-c">L2 OS 防火墙强制(硬)</span></td><td>OS 级包过滤规则(NE 框架 / WFP / nftables)阻断非隧道流量</td><td><b>进程死了也照堵</b>(规则在 OS 内核,不在 app</td><td>开机到规则生效前的窗口</td></tr>
<tr><td><span class="lvl ok-c">L3 常开 + 开机持久</span></td><td>L2 + OS 从开机起自动拉起 VPN 并强制阻断</td><td>开机窗口也堵,全程 fail-closed</td><td>(基本无死角)</td></tr>
</tbody>
</table>
<p><b>理想态 = L3。</b></p>
<div class="card root">
<b>关键认知</b>L1 与 L2 之间有一条<b>质变线</b>——L1 是「app 进程内的约束」,L2/L3 是「OS 框架的约束」。<b>只有跨过这条线才算「真 KillSwitch」</b>。目前 Pangolin 各平台用的 <code>strict_route</code> 都还停在 L1。
</div>
<h2>4. 各平台<b>能力天花板</b></h2>
<table>
<thead><tr><th>平台</th><th>机制</th><th>天花板</th><th>说明</th></tr></thead>
<tbody>
<tr><td><b>macOS</b></td><td>NetworkExtension System Extension</td><td><span class="ok-c">L3</span></td><td><code>includeAllNetworks=true</code> 全量入隧道 + <code>NEOnDemandRule</code> 常开 + <code>enforceRoutes</code>。NE 守护进程(neagent)在系统级强制,<b>扛 app 崩溃</b>。Apple 平台原生支持最完整。</td></tr>
<tr><td><b>iOS</b></td><td>NetworkExtension</td><td><span class="ok-c">L3</span></td><td>同 NE 原语,甚至更干净;on-demand「按需常开」。⚠️ 目前<b>无 iOS 客户端</b></td></tr>
<tr><td><b>Android</b></td><td>VpnService</td><td><span class="ok-c">L3</span><span class="small">,但有条件</span></td><td>app 内只能到 <b>L1</b><code>strict_route</code>);真正的 L2/L3 = 系统设置「<b>始终开启 VPN + 无 VPN 时阻止连接</b>」,OS 级强制、扛崩溃、开机生效。<b>但 app 不能编程开启</b>,只能深链引导用户手动开(或 Device Owner/MDM 下发)。</td></tr>
<tr><td><b>Windows</b></td><td>子进程 sing-box + wintun</td><td><span class="warn-c">L2(需开发)</span></td><td>现仅 <code>strict_route</code>(L1)。要到 L2 须让 app 装 <b>WFPWindows Filtering Platform)过滤器</b>阻断非隧道流量;做成系统服务持久化才能扛崩溃。</td></tr>
<tr><td><b>Linux</b><span class="small">(非主目标)</span></td><td>子进程 + tun</td><td><span class="warn-c">L2(需开发)</span></td><td>L1 现成;L2 靠 nftables/iptables killswitch 链。</td></tr>
</tbody>
</table>
<p><b>一句话</b>:Apple 两端原生能直达 L3;Android 能到 L3 但要用户手动配合;Windows/Linux 要自己写 OS 防火墙规则才能上 L2。</p>
<h2>5. 各平台<b>当前实际实现</b>(代码事实)</h2>
<table>
<thead><tr><th>平台</th><th>当前 Level</th><th>真实状态</th><th>代码位置</th></tr></thead>
<tbody>
<tr><td><b>Windows</b></td><td><span class="warn-c">L1</span></td><td>✅ 改 <code>strict_route</code> + 子进程重载 + 退避重连。进程被硬杀仍泄漏。</td><td><code>desktop_vpn_bridge.dart:305</code><code>applyKillSwitchToConfig</code>)、<code>:178</code></td></tr>
<tr><td><b>Linux</b></td><td><span class="warn-c">L1</span></td><td>同 Windows(共用 <code>DesktopVpnBridge</code>)。</td><td>同上</td></tr>
<tr><td><b>macOS</b></td><td><span class="bad-c">L0</span></td><td>❌ stub<code>setKillSwitch</code> 直接 <code>result(nil)</code><code>includeAllNetworks()→false</code>。天花板 L3,实际啥都没做。</td><td><code>VpnChannel.swift:69</code><code>PacketTunnelProvider.swift:224</code></td></tr>
<tr><td><b>Android</b></td><td><span class="bad-c">L0</span></td><td>❌ stub<code>setKillSwitch</code> 只打日志(TODO 11G)。</td><td><code>MainActivity.kt</code><code>setKillSwitch</code> 分支)</td></tr>
<tr><td><b>iOS</b></td><td></td><td>无客户端。</td><td></td></tr>
</tbody>
</table>
<p class="small">注:macOS 自 <code>kUseNativeVpnMacOS=true</code><code>vpn_bridge_provider.dart:19</code>)起走原生 System Extension,不再走 <code>DesktopVpnBridge</code>。所以 macOS 的 <code>strict_route</code>Windows 路线)对它<b>不生效</b>,必须走 NE 原生路线。</p>
<h2>6. Pangolin 现实判断与决策</h2>
<ul>
<li><b>能力与实现严重不匹配</b>:macOS 明明能 L3,现在却是 L0(最差);Windows 反而是唯一做了的(L1)。</li>
<li><b>Android 客户端方案的 KillSwitch 定位</b>MVP+ 阶段):app 内做到 <b>L1<code>strict_route</code>,与 Windows 一致)</b>,再<b>引导用户开系统 Always-on 拿到 L3</b>,UI 上诚实标注「彻底防泄漏需在系统设置开启」。这是「app 能力上限 + OS 兜底」的合理组合,不夸大。</li>
<li><b>跨端一致性缺口</b>(按性价比排序):
<ol>
<li><b>macOS 原生 11G</b><code>includeAllNetworks</code> + on-demand)—— 天花板 L3、改动集中在 Swift,<b>性价比最高</b></li>
<li><b>Windows L1→L2</b>(WFP 防火墙强制)—— 工作量较大(需原生过滤器 + 持久化服务)。</li>
<li><b>Android</b> —— 受 OS 限制,编程上限就是 L1,剩下靠引导,无更高可做空间。</li>
</ol>
</li>
</ul>
<h2>7. 关联待办</h2>
<p>KillSwitch 相关待办见项目 <code>todo/</code><code>/todo list</code>),主要三条:</p>
<ul>
<li><span class="tag info">#1 mac</span> macOS 原生 KillSwitch 补齐 L0→L3<code>includeAllNetworks</code> + on-demand</li>
<li><span class="tag info">#2 Android</span> Android KillSwitch L1<code>strict_route</code>+ 引导系统 Always-on(属 Android 客户端 11G</li>
<li><span class="tag info">#3 Windows</span> Windows KillSwitch L1→L2WFP 防火墙强制,backlog</li>
</ul>
<h2>8. 参考实现位置索引</h2>
<table>
<thead><tr><th>主题</th><th>文件</th></tr></thead>
<tbody>
<tr><td>桥接契约(<code>setKillSwitch</code> 方法签名)</td><td><code>client/lib/bridge/vpn_bridge.dart</code></td></tr>
<tr><td>平台分派(哪个平台走哪个 bridge)</td><td><code>client/lib/bridge/vpn_bridge_provider.dart</code></td></tr>
<tr><td>Windows/Linux 实现(strict_route</td><td><code>client/lib/bridge/desktop_vpn_bridge.dart</code></td></tr>
<tr><td>macOS 原生通道(stub</td><td><code>client/macos/Runner/VpnChannel.swift</code></td></tr>
<tr><td>macOS 隧道 Provider</td><td><code>client/macos/PacketTunnel/PacketTunnelProvider.swift</code></td></tr>
<tr><td>Android 通道(stub</td><td><code>client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/MainActivity.kt</code></td></tr>
<tr><td>Android VPN 服务</td><td><code>client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/PangolinVpnService.kt</code></td></tr>
</tbody>
</table>
</div>
</body>
</html>
@@ -0,0 +1,443 @@
# Pangolin Android 客户端 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 把已有的 M2 Android PoC 推到真机能连真实节点出网,并补齐切节点 + KillSwitch(清 TODO 11G)。
**Architecture:** Flutter UI 全平台共享、零改动。Android 走 `VpnService` + 内嵌 sing-box `libbox.aar`gomobile 绑定),经冻结的 `pangolin/vpn` MethodChannel/EventChannel 与 Dart 通信。配置由服务端渲染、客户端原样下发。本质是「先构建 aar → 对着真实 libbox API 修原生代码到能编译运行 → 接线 Dart → 真机端到端 → 补 11G」。
**Tech Stack:** Flutter 3.44 / Dart、Kotlin、Android `VpnService`、sing-box `v1.13.12` libboxgomobile)、Go 1.24.3、Android NDK。
**设计依据:** `docs/android-client-design.html``docs/killswitch-design.html`
## Global Constraints
- sing-box 内核版本 `v1.13.12``app/kernel/VERSION` 锚定,禁止脚本内硬编码)。
- Go 工具链:用官方 `golang.org/dl/go1.24.3` 当启动器(本机 Go 1.26.1 不动);sing-box go.mod 为 `go 1.24.7`GOTOOLCHAIN=auto 会自动下载并切到 **1.24.7** 编译。
- **gomobile = `github.com/sagernet/gomobile@v0.1.12`**SagerNet fork,非上游 `golang.org/x/mobile`;对照 sing-box v1.13.12 go.mod 与 `cmd/internal/build_libbox`)。原 VERSION 里的上游 pin 是失效 revision。
- libbox bind ldflags 必须含 **`-checklinkname=0`**(放行 `badlinkname``//go:linkname`Go 1.23+ 默认 checklinkname=1 会拒 `os.checkPidfdOnce` 等,缺则链接失败);build tags 含 `badlinkname,tfogo_checklinkname0`
- 构建需 **JDK**gomobile bind 末段 javac):用 Android Studio JBROpenJDK 21);macOS `/usr/bin/javac` 仅桩。
- 国内网络:`GOPROXY=https://goproxy.cn,direct` + `GOSUMDB=sum.golang.google.cn`proxy.golang.org 连不上)。
- Android 构建栈(Flutter 3.44 要求):**Gradle 8.7 + 声明式 plugins block + AGP 8.6.0 + Kotlin 2.2.0**(旧脚手架是 Gradle 8.0/AGP 8.1/命令式插件,全需升级迁移)。
- libbox build tags`with_quic,with_utls,with_clash_api,with_gvisor`(脚本默认,勿改)。
- `minSdkVersion 21`libbox `-androidapi 21` 要求),`targetSdkVersion` 跟随 flutter。
- Channel 契约冻结(`pangolin/vpn` + `pangolin/vpn/status` + `pangolin/vpn/stats`),方法签名见 `client/lib/bridge/vpn_bridge.dart`**不得私改**。
- 配置来源:服务端 `POST /v1/nodes/:id/connect`**客户端绝不拼配置**。
- **禁止乐观显示**:连接键 `on` 必须由 `bridge.statusStream`(内核回调)确认,`start()` 返回 ≠ on。
- 测试基线:x86_64 模拟器跑通 → Vivo X200Android 16)主力真机 → 华为 HarmonyOS 4.2 兼容性抽查。
- NDK`/Users/wangjia/Library/Android/sdk/ndk/27.0.12077973`(构建前 export `ANDROID_NDK_HOME`)。
---
### Task 1: Go 1.24.3 工具链就位
**Files:** 无(环境配置)。
**Interfaces:**
- Produces: 可执行 `go1.24.3``$(go env GOPATH)/bin/go1.24.3`),供 Task 2 构建用。
- [ ] **Step 1: 安装官方 Go 1.24.3 多版本 shim**
Run:
```bash
go install golang.org/dl/go1.24.3@latest
go1.24.3 download
```
Expected: `Downloaded ... go1.24.3 ...`,无错误。
- [ ] **Step 2: 验证版本与 NDK**
Run:
```bash
go1.24.3 version
ls -d /Users/wangjia/Library/Android/sdk/ndk/27.0.12077973
```
Expected: `go version go1.24.3 darwin/arm64`NDK 目录存在。
- [ ] **Step 3: 无需 commit**(纯环境,无文件改动)。
---
### Task 2: 构建 libbox.aar(里程碑 A
**Files:**
- 产出: `app/kernel/dist/android/libbox.aar`git 忽略产物,不入库)。
**Interfaces:**
- Consumes: Task 1 的 `go1.24.3`
- Produces: `libbox.aar`,含 `go.libbox.*` 类,供 Task 3 解析、Task 5 链接。
- [ ] **Step 1: 用 Go 1.24.3 跑构建脚本**
`build-android.sh` 内部用 `command -v go` 取 Go,需临时让 `go` 指向 1.24.3。用 PATH 注入 shim 目录的方式(不改脚本):
Run:
```bash
cd /Users/wangjia/code/pangolin/.claude/worktrees/feature+android/app/kernel
export ANDROID_NDK_HOME=/Users/wangjia/Library/Android/sdk/ndk/27.0.12077973
GOROOT_1243="$(go1.24.3 env GOROOT)"
PATH="${GOROOT_1243}/bin:${PATH}" go version # 确认此 shell 内 go=1.24.3
PATH="${GOROOT_1243}/bin:${PATH}" bash build-android.sh
```
Expected: 末尾 `✓ 构建完成: .../dist/android/libbox.aar` + 三个 `.so`arm64-v8a/armeabi-v7a/x86_64)列出。
注意:首次会 `git clone sing-box`(约数分钟)+ `gomobile bind`(数分钟~十几分钟)。
- [ ] **Step 2: 验证产物三 ABI 齐**
Run:
```bash
unzip -l app/kernel/dist/android/libbox.aar | grep -E '\.so$'
```
Expected: 至少 `jni/arm64-v8a/libgojni.so``jni/armeabi-v7a/...``jni/x86_64/...` 三条。
- [ ] **Step 3: 无需 commit**aar 为忽略产物)。若 `gomobile bind` 失败,记录报错——多半是 Go/gomobile 组合问题,回到 Task 1 复核版本。
---
### Task 3: 解析真实 libbox API(里程碑 B 发现)
**Files:**
- 创建: `app/kernel/dist/android/libbox-api.txt`javap 输出存档,便于 Task 5/9 对照;可入库或留作临时参考)。
**Interfaces:**
- Consumes: Task 2 的 `libbox.aar`
- Produces: `go.libbox` 关键类的真实方法签名清单(`Libbox``BoxService``TunOptions``PlatformInterface``CommandClient``CommandClientHandler``CommandClientOptions``StatusMessage``OutboundGroupIterator`)。
- [ ] **Step 1: 解包 aar 取 classes.jarjavap 导出签名**
Run:
```bash
cd /Users/wangjia/code/pangolin/.claude/worktrees/feature+android/app/kernel/dist/android
mkdir -p .inspect && cd .inspect
unzip -o ../libbox.aar classes.jar >/dev/null
for c in Libbox BoxService TunOptions PlatformInterface CommandClient CommandClientHandler CommandClientOptions StatusMessage OutboundGroupIterator BoxService\$Companion; do
echo "===== go.libbox.$c ====="
javap -classpath classes.jar "go.libbox.$c" 2>/dev/null || echo "(类不存在,名称可能不同)"
done | tee ../libbox-api.txt
```
Expected: 各类方法签名打印到屏幕与 `libbox-api.txt`。重点记录:
- 创建 BoxService 的入口(`Libbox.newService` / `new BoxService(...)` / 参数个数与类型)。
- `TunOptions` 的 getter 真名(`getInet4Address()` vs `inet4Address()``getMTU()`/`getMtu()`)。
- `CommandClient` 选 outbound 的方法(如 `selectOutbound(group, tag)`)。
- `StatusMessage` 流量字段(`getUploadTotal()` 等)。
- [ ] **Step 2: 比对现有 Kotlin 的「猜测点」**
Run:
```bash
grep -n "// libbox API" /Users/wangjia/code/pangolin/.claude/worktrees/feature+android/client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/PangolinVpnService.kt
```
Expected: 列出所有标注「libbox API」的行;逐条对照 `libbox-api.txt` 标记需改的方法名(用于 Task 5)。
- [ ] **Step 3: Commit(存档 API 清单)**
```bash
cd /Users/wangjia/code/pangolin/.claude/worktrees/feature+android
git add app/kernel/dist/android/libbox-api.txt
git commit -m "docs(android): 存档 libbox.aar 真实 API 签名清单(Task 3)"
```
(若 `dist/` 被 .gitignore 忽略,改存到 `docs/` 下并相应调整路径。)
---
### Task 4: Dart provider 接 Android → VpnNativeBridge
**Files:**
- Modify: `client/lib/bridge/vpn_bridge_provider.dart`(约 27 行,加 Android 分派)。
**Interfaces:**
- Consumes: 现有 `VpnNativeBridge``vpn_bridge.dart`,走 `pangolin/vpn` 通道)。
- Produces: Android 运行时返回 `VpnNativeBridge` 而非 mock。
- [ ] **Step 1: 改平台分派**
`vpn_bridge_provider.dart` 中:
```dart
if (Platform.isMacOS || Platform.isLinux || Platform.isWindows) {
return DesktopVpnBridge();
}
```
之前加入 Android 分支:
```dart
if (Platform.isAndroid) {
return VpnNativeBridge();
}
```
并把文件顶部注释「其他平台(iOS / Web / 测试)→ VpnBridgeMock」更新为「iOS / Web / 测试 → VpnBridgeMockAndroid → VpnNativeBridge」。
- [ ] **Step 2: 静态分析**
Run:
```bash
cd /Users/wangjia/code/pangolin/.claude/worktrees/feature+android/client
flutter analyze lib/bridge/vpn_bridge_provider.dart
```
Expected: `No issues found!`
- [ ] **Step 3: Commit**
```bash
cd /Users/wangjia/code/pangolin/.claude/worktrees/feature+android
git add client/lib/bridge/vpn_bridge_provider.dart
git commit -m "feat(android): Dart provider 接 Android→VpnNativeBridge(Task 4)"
```
---
### Task 5: 修原生代码对齐真实 API 并编译通过(里程碑 B)
**Files:**
- Modify: `client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/PangolinVpnService.kt`(按 Task 3 清单逐处校正方法名)。
- Modify(按需): `MainActivity.kt``VpnEventBus.kt`
**Interfaces:**
- Consumes: Task 2 的 `libbox.aar``build.gradle` 已配 `implementation files(libboxAar)`)、Task 3 的 `libbox-api.txt`
- Produces: 可编译的 debug APK。
- [ ] **Step 1: 按 Task 3 清单校正 `PangolinVpnService.kt` 的 libbox 调用**
对照 `libbox-api.txt`,逐处替换标注 `// libbox API` 的方法名(`Libbox.newBoxService`/`service.start()`/`TunOptions.inet4Address()`/`mtu()`/`CommandClientOptions` 字段/`StatusMessage` getter 等)为真实签名。`TunOptions` getter 已有 try-catch 兜底,优先改成真名以免走兜底默认值。
- [ ] **Step 2: 构建 debug APK(只 arm64 加速首轮)**
Run:
```bash
cd /Users/wangjia/code/pangolin/.claude/worktrees/feature+android/client
flutter build apk --debug --target-platform android-arm64
```
Expected: `✓ Built build/app/outputs/flutter-apk/app-debug.apk`。若报 Kotlin 编译错(未知方法/类型不符)→ 回 Step 1 对照 `libbox-api.txt` 再修。
- [ ] **Step 3: 全量 analyze**
Run:
```bash
flutter analyze
```
Expected: `No issues found!`(或仅既有无关告警)。
- [ ] **Step 4: Commit**
```bash
cd /Users/wangjia/code/pangolin/.claude/worktrees/feature+android
git add client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/
git commit -m "fix(android): PangolinVpnService 对齐真实 libbox API,编译通过(Task 5)"
```
---
### Task 6: 模拟器冒烟——启动 + 通道连通
**Files:** 无代码改动(验证任务)。
**Interfaces:**
- Consumes: Task 4/5 产物。
- Produces: 确认 app 在 x86_64 模拟器启动、通道注册、初始状态 `off`
- [ ] **Step 1: 起 x86_64 模拟器并装运行**
Run:
```bash
adb devices # 确认有 emulator-xxxx;无则先在 Android Studio 启一个 x86_64 AVD
cd /Users/wangjia/code/pangolin/.claude/worktrees/feature+android/client
flutter run -d emulator --debug # 或 flutter run 选模拟器
```
Expected: app 启动到登录/主页,无崩溃。
- [ ] **Step 2: 验证通道初始状态**
观察 logcat
```bash
adb logcat -s PangolinMainActivity PangolinVpnService flutter | head -40
```
Expected: 见 `MethodChannel: getStatus`、status channel `onListen``off`UI 连接键为 off 态。
- [ ] **Step 3: 无需 commit。**
---
### Task 7: 模拟器端到端连通(里程碑 C)
**Files:** 无代码改动(验证任务;若发现 openTun/DNS 问题则回 Task 5 修)。
**Interfaces:**
- Consumes: 服务端真实 config(经登录→选节点→`fetchConfig` 自动获取)。
- Produces: 真隧道连通、可访问被墙站点。
- [ ] **Step 1: 登录并连接**
在 app 内登录(真账号)→ 选 RackNerd 节点 → 点连接。授予 VPN 授权弹窗。
观察:
```bash
adb logcat -s PangolinVpnService | grep -E "openTun|BoxService started|postStatus"
```
Expected: `openTun: fd=...``BoxService started` → status `on`;连接键转 on(计时开始)。
- [ ] **Step 2: 验证真出网 + DNS(铁律)**
在模拟器浏览器或 `adb shell` 访问被墙站点:
```bash
adb shell curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" https://www.google.com
```
Expected: `200 ...`。若超时但能连其它国内站 → 多半 DNS 劫持规则缺失,检查服务端下发 config 的 `route.rules` 首条是否 `hijack-dns`
- [ ] **Step 3: 断开验证**
点断开 → status `off`、连接键复位、`doStop` 日志干净(无重入崩溃)。
- [ ] **Step 4: 无代码则无 commit;若 Task 5 文件有修复则 commit。**
---
### Task 8: 统计走字(里程碑 D)
**Files:** 验证为主;若字段不准则 Modify `PangolinVpnService.kt``writeStatus` 映射)。
**Interfaces:**
- Consumes: libbox `CommandClient` STATUS(或 TrafficStats 兜底)。
- Produces: `pangolin/vpn/stats` 每秒一帧,UI 统计页跳动。
- [ ] **Step 1: 观察 stats 帧**
连接状态下:
```bash
adb logcat -s PangolinVpnService | grep -E "CommandClient connected|TrafficStats fallback"
```
Expected: 见 `CommandClient connected`(优先)或兜底日志;UI 统计页上/下行字节与速率每秒更新。
- [ ] **Step 2: 校正字段(如需)**
若速率恒 0 或字节不增 → 对照 Task 3 的 `StatusMessage` getter 真名修 `writeStatus` 映射,重编重测。
- [ ] **Step 3: Commit(如有修复)**
```bash
git add client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/PangolinVpnService.kt
git commit -m "fix(android): 校正统计字段映射(Task 8)"
```
---
### Task 9: 切节点 selectOutbound + getActiveOutbound(里程碑 E / 11G
**Files:**
- Modify: `PangolinVpnService.kt`(加 libbox CommandClient group 选择 + 查询)。
- Modify: `MainActivity.kt``selectOutbound`/`getActiveOutbound` 从 stub 改为转发到 Service)。
**Interfaces:**
- Consumes: Task 3 的 CommandClient 选 outbound API 签名;服务端 config 的出口组名(`auto` urltest + `reality-out`/`hy2-out`)。
- Produces: `selectOutbound(tag)` 切换不断连;`getActiveOutbound()` 返回当前出口 tag。语义复刻桌面 `desktop_vpn_bridge.dart``selectProxy`/`getProxies`
- [ ] **Step 1: 确认组名**
```bash
grep -n "\"tag\"\|selector\|urltest\|proxyTags" /Users/wangjia/code/pangolin/.claude/worktrees/feature+android/server/internal/httpapi/clientconfig.go
```
Expected: 确认可选组名(当前为 urltest `auto`;如需手动选需服务端含 `selector` 组——若没有则 selectOutbound 作用于 `auto` 组内成员或触发服务端补 selector,按实际 config 决定)。
- [ ] **Step 2: 实现 Service 侧 selectOutbound/getActiveOutbound**
`PangolinVpnService` 暴露静态/单例方法,用 libbox `CommandClient`(按 Task 3 真名)执行 group 选择与查询;`MainActivity` 的对应 MethodChannel 分支从 stub 改为调用之。
- [ ] **Step 3: 编译 + 真机/模拟器验证**
Run:
```bash
cd client && flutter build apk --debug --target-platform android-arm64
```
连接后在节点页切换出口:观察不断连、`getActiveOutbound` 返回值随之变、UI 高亮正确。
- [ ] **Step 4: Commit**
```bash
git add client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/
git commit -m "feat(android): selectOutbound/getActiveOutbound 经 libbox CommandClient 切节点(Task 9,11G)"
```
---
### Task 10: KillSwitch L1 + 引导系统 Always-on(里程碑 F / 11G
**Files:**
- Modify: `MainActivity.kt``setKillSwitch` 从 stub 改为:缓存偏好 + 触发以 `strict_route` 重启隧道;并暴露跳系统 Always-on 设置的方法)。
- Modify: `PangolinVpnService.kt`(启动时按偏好将 config TUN `strict_route` 置位——可复用桌面 `applyKillSwitchToConfig` 的等价 JSON 改写逻辑,在 Kotlin 侧实现)。
- ModifyUI 文案): `client/lib/l10n/strings_zh.dart` + `strings_en.dart`KillSwitch 旁诚实标注「彻底防泄漏需到系统设置开启 Always-on」)。
**Interfaces:**
- Consumes: 设计依据 `docs/killswitch-design.html`L1 语义 + 引导 L3)。
- Produces: `setKillSwitch(on)``strict_route`(与 Windows 一致)+ 系统 Always-on 引导入口。
- [ ] **Step 1: Kotlin 侧实现 strict_route 改写**
在 Service 启动前对 configJson 做 JSON 改写:`inbounds``type==tun` 的项设 `strict_route = on``setKillSwitch(on)` 若隧道在跑则以新偏好重启(短暂重连,与桌面一致)。
- [ ] **Step 2: Always-on 引导入口**
`setKillSwitch(on)` 为 true 时(或 UI 按钮)跳转系统 VPN 设置:
```kotlin
startActivity(Intent("android.settings.VPN_SETTINGS"))
```
(部分 ROM 落不到「Always-on」子页,落到 VPN 列表即可;华为/Vivo 单独适配留 Task 11。)
- [ ] **Step 3: UI 文案诚实标注**
在 KillSwitch 开关旁加说明文案(走 l10n,**无红线词**),明确 app 内 = L1、彻底防泄漏需系统 Always-on。
- [ ] **Step 4: 编译 + analyze + 验证**
Run:
```bash
cd client && flutter build apk --debug --target-platform android-arm64 && flutter analyze
```
连接下开 KillSwitch:观察重连后 `strict_route` 生效(停内核即断网);点引导跳到系统 VPN 设置。
- [ ] **Step 5: Commit**
```bash
git add client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/ client/lib/l10n/
git commit -m "feat(android): KillSwitch L1(strict_route)+引导系统 Always-on(Task 10,11G)"
```
---
### Task 11: 真机验证(Vivo X200 + 华为 4.2
**Files:** 验证为主;厂商保活引导若需适配则 Modify `MainActivity.kt`
**Interfaces:**
- Consumes: 全部前序产物。
- Produces: 真机端到端连通 + 后台保活验证 + 厂商电池优化引导适配。
- [ ] **Step 1: Vivo X200Android 16,主力)全链路**
插线 `adb devices` 确认 → `flutter run -d <vivo> --release` 或装 release APK。跑通:登录→连接→访问被墙站→切节点→KillSwitch→断开。重点验证 **targetSdk 高版本下前台服务 specialUse 不被限**、后台挂 30 分钟不被杀(需在 Vivo 设置允许后台+自启动)。
- [ ] **Step 2: 华为 HarmonyOS 4.2(兼容抽查)**
装 APK 跑通连接主链路。华为后台保活更激进——验证电池优化引导 Intent 是否落对页面;若落不到,加华为定制 Intent 兜底(`Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` 之外的 ROM 专属页)。
- [ ] **Step 3: Commit(如有厂商适配)**
```bash
git add client/android/app/src/main/kotlin/com/pangolin/pangolin_vpn/MainActivity.kt
git commit -m "fix(android): 厂商(Vivo/华为)后台保活引导适配(Task 11)"
```
- [ ] **Step 4: 标记 todo #2 完成待验收**
```bash
node ~/.claude/skills/todo/todo.mjs status 2 done
```
---
## 验收总览(对应里程碑)
| 里程碑 | Task | 完成判据 |
|---|---|---|
| A 构建内核 | 2 | libbox.aar 三 ABI 齐 |
| B 编译链接 | 35 | debug APK 构建成功、analyze 零警告 |
| C 端到端连通 | 6–7 | 模拟器实测科学上网、UI 三态正确 |
| D 统计走字 | 8 | stats 每秒跳动且数值合理 |
| E 切节点 | 9 | 切出口不断连、当前出口正确 |
| F KillSwitch | 10 | strict_route 生效 + Always-on 引导 + 诚实文案 |
| 真机 | 11 | Vivo X200 全链路 + 华为兼容抽查 + 后台保活 |
+645
View File
@@ -0,0 +1,645 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>feature+windows — 项目 TODO</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0; padding: 0 0 80px;
font-family: "Noto Sans SC", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #1f2933; background: #f7f9fb; line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
header {
background: linear-gradient(135deg, #0A1F3B 0%, #15407D 100%);
color: #fff; padding: 36px 32px 28px;
}
header h1 { margin: 0 0 6px; font-size: 24px; font-weight: 700; }
header .header-meta { color: #ADC9EA; font-size: 13px; margin-top: 4px; }
.stats { display: flex; gap: 16px; margin-top: 14px; flex-wrap: wrap; }
.stat-pill {
background: rgba(255,255,255,0.12); border-radius: 20px;
padding: 4px 14px; font-size: 13px; color: #fff;
}
.stat-pill strong { font-size: 18px; font-weight: 700; margin-right: 2px; }
/* ── 筛选栏 ── */
.filter-bar {
position: sticky; top: 0; z-index: 10;
background: #fff; border-bottom: 1px solid #e4e9ef;
padding: 10px 28px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center;
}
.filter-group { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.filter-label { font-size: 12px; color: #7b8794; white-space: nowrap; }
.filter-chip, .filter-btn {
padding: 3px 12px; border-radius: 14px; border: 1.5px solid #d1d9e6;
background: #f7f9fb; color: #52606d; font-size: 12.5px; cursor: pointer;
transition: all .15s; white-space: nowrap;
}
.filter-chip:hover, .filter-btn:hover { border-color: #2563AC; color: #2563AC; }
.filter-chip.active, .filter-btn.active {
background: #2563AC; border-color: #2563AC; color: #fff;
}
.filter-sep { width: 1px; height: 20px; background: #e4e9ef; margin: 0 4px; }
/* ── 内容区 ── */
.wrap { max-width: 980px; margin: 0 auto; padding: 0 24px; }
.section-block { margin-bottom: 8px; }
.section-title {
font-size: 15px; font-weight: 700;
padding: 10px 14px; border-radius: 8px;
display: flex; align-items: center; gap: 8px; cursor: pointer;
margin: 20px 0 8px; user-select: none;
}
.section-title .s-count {
font-size: 12px; font-weight: 500; padding: 1px 8px; border-radius: 10px;
background: rgba(0,0,0,0.08);
}
.section-title .s-arrow { margin-left: auto; font-size: 12px; color: inherit; opacity: .6; }
/* 状态主题色 */
.st-open { background: #eff6ff; color: #1e40af; border-left: 4px solid #3b82f6; }
.st-doing { background: #fff7ed; color: #9a3412; border-left: 4px solid #f97316; }
.st-done { background: #fefce8; color: #854d0e; border-left: 4px solid #eab308; }
.st-accepted{ background: #f0fdf4; color: #166534; border-left: 4px solid #22c55e; }
ul.todo-list { list-style: none; margin: 0; padding: 0; }
.todo-card {
background: #fff; border: 1px solid #e4e9ef; border-radius: 10px;
padding: 14px 18px; margin-bottom: 10px;
border-left-width: 4px;
}
.todo-card.s-open { border-left-color: #3b82f6; }
.todo-card.s-doing { border-left-color: #f97316; }
.todo-card.s-done { border-left-color: #eab308; }
.todo-card.s-accepted { border-left-color: #22c55e; background: #f0fdf4; opacity: .8; }
.card-header { display: flex; align-items: flex-start; gap: 10px; flex-wrap: wrap; }
.item-title { font-weight: 600; font-size: 15px; flex: 1; }
.todo-card.s-accepted .item-title { text-decoration: line-through; color: #52606d; }
.card-badges { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
.item-desc { font-size: 13.5px; color: #52606d; margin-top: 6px; }
.item-desc code {
background: #f0f2f5; padding: 1px 5px; border-radius: 3px;
font-family: "JetBrains Mono", monospace; font-size: 12.5px; color: #b91c1c;
}
.card-footer { display: flex; align-items: center; gap: 10px; margin-top: 10px; flex-wrap: wrap; }
.tag-row { display: flex; flex-wrap: wrap; gap: 6px; flex: 1; }
.item-meta { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; color: #8aa3c4; }
.meta-date { white-space: nowrap; }
/* ── 标签样式 ── */
.tag { display: inline-block; padding: 2px 9px; border-radius: 10px; font-size: 12px; font-weight: 600; white-space: nowrap; }
.t-block { background: #fde8e8; color: #b91c1c; }
.t-high { background: #fef0d8; color: #9a6700; }
.t-low { background: #e6f0fb; color: #1d4ed8; }
.t-tag { background: #efefef; color: #555; cursor: pointer; transition: .12s; }
.t-tag:hover { background: #d9e8fb; color: #1d4ed8; }
.ver-badge { font-weight: 700; color: #1f7a44; }
.sub-progress-badge { background: #f0f4ff; color: #3730a3; }
/* 状态徽章 */
.status-badge { font-size: 11.5px; }
.s-open { background: #dbeafe; color: #1e40af; }
.s-doing { background: #ffedd5; color: #9a3412; }
.s-done { background: #fef9c3; color: #854d0e; }
.s-accepted { background: #dcfce7; color: #166534; }
/* ── 折叠 ── */
.section-list-wrap.collapsed { display: none; }
.empty-tip { font-size: 14px; color: #8aa3c4; margin: 8px 0 24px; }
.hidden { display: none !important; }
/* ── 改动等级徽章 ── */
.tier-1 { background: #ede9fe; color: #6d28d9; }
.tier-2 { background: #ccfbf1; color: #0f766e; }
.tier-3 { background: #f1f5f9; color: #475569; }
/* ── 拒绝按钮 & 拒绝原因 ── */
.reject-btn {
padding: 3px 10px; border-radius: 6px; border: 1.5px solid #dc2626;
background: #fff; color: #dc2626; font-size: 12px; font-weight: 600;
cursor: pointer; transition: all .15s; white-space: nowrap;
}
.reject-btn:hover { background: #dc2626; color: #fff; }
.reject-note {
margin: 8px 0 4px; padding: 7px 12px; border-radius: 6px;
background: #fef2f2; border: 1px solid #fecaca;
font-size: 13px; color: #b91c1c; line-height: 1.5;
}
.reject-date { color: #ef9999; font-size: 12px; }
/* ── 确认闸(方案/改动说明)── */
.gate-block { margin: 10px 0 4px; padding: 10px 14px; border-radius: 8px; font-size: 13px; }
.gate-pending { background: #fff7ed; border: 1px solid #fdba74; }
.gate-granted { background: #f0fdf4; border: 1px solid #bbf7d0; }
.gate-info { background: #f1f5f9; border: 1px solid #e2e8f0; }
.gate-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; }
.gate-badge { padding: 2px 9px; border-radius: 10px; font-size: 11.5px; font-weight: 700; white-space: nowrap; }
.gate-badge.pending { background: #f97316; color: #fff; }
.gate-badge.granted { background: #22c55e; color: #fff; }
.gate-badge.info { background: #94a3b8; color: #fff; }
.gate-kind { font-size: 12px; color: #52606d; }
.gate-date { font-size: 12px; color: #8aa3c4; margin-left: auto; }
.gate-note { color: #52606d; margin: 4px 0; white-space: pre-wrap; }
.gate-ref { font-size: 12.5px; color: #52606d; margin-top: 4px; }
.gate-note code, .gate-ref code {
background: #fff; padding: 1px 5px; border-radius: 3px;
font-family: "JetBrains Mono", monospace; font-size: 12px; color: #b91c1c;
}
.approve-btn {
margin-top: 8px; padding: 5px 14px; border-radius: 6px; border: none;
background: #16a34a; color: #fff; font-size: 12.5px; font-weight: 600; cursor: pointer;
transition: background .15s;
}
.approve-btn:hover { background: #15803d; }
.approve-cmd { margin-top: 10px; padding: 10px 12px; background: #1e293b; border-radius: 8px; }
.approve-cmd-label { font-size: 12px; color: #94a3b8; margin-bottom: 6px; }
.approve-cmd-code { font-family: "JetBrains Mono", monospace; font-size: 13px; color: #86efac; display: block; word-break: break-all; }
.approve-copy-btn { margin-top: 8px; padding: 4px 12px; border-radius: 6px; background: #334155; color: #e2e8f0; border: none; font-size: 12px; cursor: pointer; }
.approve-copy-btn:hover { background: #475569; }
.stat-pill.gate-stat { background: #f97316; }
/* ── 子任务区 ── */
.subtask-block {
margin-top: 12px; padding: 10px 14px;
background: #f8fafc; border: 1px solid #e4e9ef; border-radius: 8px;
}
.subtask-header {
display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap;
}
.subtask-label {
font-size: 11px; font-weight: 700; color: #7b8794;
text-transform: uppercase; letter-spacing: .6px;
}
.subtask-progress-text { font-size: 12px; color: #52606d; }
.subtask-progress-bar {
flex: 1; height: 5px; background: #e4e9ef; border-radius: 3px; min-width: 60px;
}
.subtask-progress-fill { height: 100%; background: #22c55e; border-radius: 3px; transition: width .4s; }
.subtask-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 5px; }
.subtask-item {
display: flex; align-items: center; gap: 7px; flex-wrap: wrap;
padding: 6px 10px; border-radius: 6px; font-size: 13px;
background: #fff; border: 1px solid #e8edf2;
}
.subtask-item.s-done { background: #f0fdf4; border-color: #bbf7d0; }
.subtask-item.s-doing { background: #fff7ed; border-color: #fed7aa; }
.subtask-item.s-accepted { background: #f0fdf4; border-color: #bbf7d0; opacity: .75; }
.sub-icon {
width: 16px; text-align: center; font-size: 13px; font-weight: 700; flex-shrink: 0;
}
.sub-icon.s-done { color: #16a34a; }
.sub-icon.s-doing { color: #ea580c; }
.sub-icon.s-open { color: #93aec8; }
.sub-icon.s-accepted { color: #16a34a; }
.sub-sid {
font-family: "JetBrains Mono", monospace; font-size: 11.5px;
color: #52606d; font-weight: 700; min-width: 32px; flex-shrink: 0;
}
.sub-title { flex: 1; font-size: 13px; min-width: 120px; }
.subtask-item.s-done .sub-title,
.subtask-item.s-accepted .sub-title { text-decoration: line-through; color: #52606d; }
.sub-badges { display: flex; gap: 4px; flex-wrap: wrap; align-items: center; }
.dep-section { display: flex; align-items: center; gap: 3px; }
.dep-label { font-size: 11px; color: #8aa3c4; margin-right: 2px; }
.dep-badge {
padding: 1px 6px; border-radius: 8px; font-size: 11px;
font-weight: 700; font-family: monospace; cursor: default;
}
.dep-done { background: #dcfce7; color: #166534; }
.dep-pending { background: #fee2e2; color: #b91c1c; }
/* ── 拒绝 Modal ── */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,.45);
display: flex; align-items: center; justify-content: center; z-index: 999;
}
.modal-box {
background: #fff; border-radius: 12px; padding: 28px 32px;
width: min(480px, 94vw); box-shadow: 0 20px 60px rgba(0,0,0,.2);
}
.modal-box h3 { margin: 0 0 4px; font-size: 17px; color: #1f2933; }
.modal-subtitle { font-size: 13px; color: #52606d; margin: 0 0 18px; }
.modal-label { font-size: 13px; font-weight: 600; color: #374151; margin-bottom: 6px; display: block; }
.modal-label .req { color: #dc2626; margin-left: 2px; }
.modal-textarea {
width: 100%; border: 1.5px solid #d1d9e6; border-radius: 8px;
padding: 10px 12px; font-size: 14px; font-family: inherit; resize: vertical;
min-height: 88px; outline: none; transition: border-color .15s;
}
.modal-textarea:focus { border-color: #dc2626; }
.modal-textarea.error { border-color: #dc2626; background: #fff8f8; }
.modal-err { font-size: 12px; color: #dc2626; margin-top: 4px; display: none; }
.modal-err.show { display: block; }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; }
.modal-btn { padding: 8px 20px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; border: none; transition: all .15s; }
.modal-btn-cancel { background: #f3f4f6; color: #374151; }
.modal-btn-cancel:hover { background: #e5e7eb; }
.modal-btn-reject { background: #dc2626; color: #fff; }
.modal-btn-reject:hover { background: #b91c1c; }
.modal-cmd-wrap { margin-top: 16px; padding: 12px 14px; background: #1e293b; border-radius: 8px; }
.modal-cmd-label { font-size: 12px; color: #94a3b8; margin-bottom: 8px; }
.modal-cmd-code { font-family: "JetBrains Mono", "Fira Code", monospace; font-size: 13px; color: #86efac; word-break: break-all; display: block; }
.modal-copy-btn { margin-top: 10px; padding: 5px 14px; border-radius: 6px; background: #334155; color: #e2e8f0; border: none; font-size: 12px; cursor: pointer; }
.modal-copy-btn:hover { background: #475569; }
</style>
</head>
<body>
<header>
<div class="wrap">
<h1>feature+windows — 项目 TODO</h1>
<div class="header-meta">生成于 2026-06-22 · 真相源 todo/todo.json</div>
<div class="stats">
<div class="stat-pill"><strong>3</strong>全部</div>
<div class="stat-pill"><strong>3</strong>待开始</div>
<div class="stat-pill"><strong>0</strong>开发中</div>
<div class="stat-pill"><strong>0</strong>待验收</div>
<div class="stat-pill"><strong>0</strong>已验收</div>
</div>
</div>
</header>
<div class="filter-bar" id="filterBar">
<div class="filter-group">
<span class="filter-label">重要度</span>
<button class="filter-btn active" data-filter-level="all">全部</button>
<button class="filter-btn" data-filter-level="high">高优</button>
<button class="filter-btn" data-filter-level="mid">重要</button>
<button class="filter-btn" data-filter-level="low">一般</button>
</div>
<div class="filter-sep"></div>
<div class="filter-group">
<span class="filter-label">状态</span>
<button class="filter-btn active" data-filter-status="all">全部</button>
<button class="filter-btn" data-filter-status="open">待开始</button>
<button class="filter-btn" data-filter-status="doing">开发中</button>
<button class="filter-btn" data-filter-status="done">待验收</button>
<button class="filter-btn" data-filter-status="accepted">已验收</button>
</div>
<div class="filter-sep"></div>
<div class="filter-group">
<span class="filter-label">改动等级</span>
<button class="filter-btn active" data-filter-tier="all">全部</button>
<button class="filter-btn" data-filter-tier="1">一级</button>
<button class="filter-btn" data-filter-tier="2">二级</button>
<button class="filter-btn" data-filter-tier="3">三级</button>
</div>
<div class="filter-sep"></div>
<div class="filter-group">
<span class="filter-label">平台 / 标签</span>
<button class="filter-chip" data-filter-tag="Android">Android</button><button class="filter-chip" data-filter-tag="Windows">Windows</button><button class="filter-chip" data-filter-tag="mac">mac</button>
</div>
</div>
<div class="wrap">
<div class="section-block" id="section-open">
<div class="section-title st-open" data-toggle="open">
📋 待开始 <span class="s-count">3</span>
<span class="s-arrow">▴ 收起</span>
</div>
<div class="section-list-wrap " id="list-wrap-open">
<ul class="todo-list" id="list-open">
<li class="todo-card s-open"
data-id="1"
data-level="mid"
data-status="open"
data-tier="2"
data-tags="mac">
<div class="card-header">
<span class="item-title">macOS 原生 KillSwitch 补齐 L0→L3</span>
<div class="card-badges">
<span class="tag status-badge s-open">待开始</span>
<span class="tag t-high">重要</span>
<span class="tag tier-2">二级</span>
</div>
</div>
<div class="item-desc">现状 L0(stub)VpnChannel.swift:69 setKillSwitch 直接 result(nil)、includeAllNetworks()→false。天花板 L3。改 NetworkExtensionincludeAllNetworks=true + NEOnDemandRule 常开 + enforceRoutes,由 neagent 系统级强制、扛 app 崩溃。性价比最高的跨端缺口。依据见 docs/killswitch-design.md §4-6。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="mac">mac</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-22</span>
</div>
</div>
</li>
<li class="todo-card s-open"
data-id="2"
data-level="mid"
data-status="open"
data-tier="2"
data-tags="Android">
<div class="card-header">
<span class="item-title">Android KillSwitch L1 + 引导系统 Always-on</span>
<div class="card-badges">
<span class="tag status-badge s-open">待开始</span>
<span class="tag t-high">重要</span>
<span class="tag tier-2">二级</span>
</div>
</div>
<div class="item-desc">现状 L0(stub)MainActivity.kt setKillSwitch 只打日志(TODO 11G)。目标:app 内做到 L1(config TUN strict_route,复刻 Windows 的 applyKillSwitchToConfig 语义) + 深链引导用户开系统设置「始终开启 VPN + 无 VPN 时阻止连接」拿 L3,UI 诚实标注「彻底防泄漏需在系统设置开启」。属 Android 客户端 11G。依据见 docs/killswitch-design.md §4-6。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-22</span>
</div>
</div>
</li>
<li class="todo-card s-open"
data-id="3"
data-level="low"
data-status="open"
data-tier="1"
data-tags="Windows">
<div class="card-header">
<span class="item-title">Windows KillSwitch L1→L2 (WFP 防火墙强制)</span>
<div class="card-badges">
<span class="tag status-badge s-open">待开始</span>
<span class="tag t-low">一般 / 优化</span>
<span class="tag tier-1">一级</span>
</div>
</div>
<div class="item-desc">现状 L1desktop_vpn_bridge.dart strict_route + 子进程重载,进程被硬杀仍泄漏。目标 L2:app 装 WFP(Windows Filtering Platform) 过滤器阻断非隧道流量,做成系统服务持久化才能扛崩溃。工作量较大,backlog。依据见 docs/killswitch-design.md §4-6。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="Windows">Windows</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-22</span>
</div>
</div>
</li>
</ul>
</div>
</div>
<div class="section-block" id="section-doing">
<div class="section-title st-doing" data-toggle="doing">
🔨 开发中 <span class="s-count">0</span>
<span class="s-arrow">▴ 收起</span>
</div>
<div class="section-list-wrap " id="list-wrap-doing">
<ul class="todo-list" id="list-doing">
<p class="empty-tip">暂无条目</p>
</ul>
</div>
</div>
<div class="section-block" id="section-done">
<div class="section-title st-done" data-toggle="done">
🔍 待验收 <span class="s-count">0</span>
<span class="s-arrow">▴ 收起</span>
</div>
<div class="section-list-wrap " id="list-wrap-done">
<ul class="todo-list" id="list-done">
<p class="empty-tip">暂无条目</p>
</ul>
</div>
</div>
<div class="section-block" id="section-accepted">
<div class="section-title st-accepted" data-toggle="accepted">
✅ 已验收 <span class="s-count">0</span>
<span class="s-arrow">▾ 展开</span>
</div>
<div class="section-list-wrap collapsed" id="list-wrap-accepted">
<ul class="todo-list" id="list-accepted">
<p class="empty-tip">暂无条目</p>
</ul>
</div>
</div>
</div>
<!-- 拒绝验收 Modal -->
<div id="reject-modal" class="modal-overlay" style="display:none">
<div class="modal-box">
<h3>拒绝验收</h3>
<p class="modal-subtitle" id="reject-modal-subtitle"></p>
<label class="modal-label">拒绝原因<span class="req">*</span></label>
<textarea class="modal-textarea" id="reject-reason-input" rows="3" placeholder="请说明具体问题,Claude 将以此为依据修复…"></textarea>
<div class="modal-err" id="reject-reason-err">请填写拒绝原因</div>
<div id="reject-cmd-wrap" class="modal-cmd-wrap" style="display:none">
<div class="modal-cmd-label">在终端运行以下命令:</div>
<code class="modal-cmd-code" id="reject-cmd-text"></code>
<button class="modal-copy-btn" id="reject-copy-btn">复制命令</button>
</div>
<div class="modal-actions">
<button class="modal-btn modal-btn-cancel" id="reject-cancel-btn">取消</button>
<button class="modal-btn modal-btn-reject" id="reject-confirm-btn">确认拒绝</button>
</div>
</div>
</div>
<script>
(function() {
let curLevel = 'all';
let curStatus = 'all';
let curTier = 'all';
let curTags = new Set();
// 通过本地服务(http)打开时,按钮直接写库;用 file:// 直接打开则回退为「复制命令」
const SERVED = location.protocol === 'http:' || location.protocol === 'https:';
function postAction(url, payload) {
return fetch(url, {
method: 'POST',
headers: payload ? { 'Content-Type': 'application/json' } : undefined,
body: payload ? JSON.stringify(payload) : undefined,
}).then(r => r.json());
}
function applyFilter() {
document.querySelectorAll('.todo-card').forEach(card => {
const lvl = card.dataset.level;
const status = card.dataset.status;
const tier = card.dataset.tier;
const tags = card.dataset.tags ? card.dataset.tags.split(',') : [];
let show = true;
if (curLevel !== 'all' && lvl !== curLevel) show = false;
if (curStatus !== 'all' && status !== curStatus) show = false;
if (curTier !== 'all' && String(tier) !== curTier) show = false;
if (curTags.size > 0 && ![...curTags].some(t => tags.includes(t))) show = false;
card.classList.toggle('hidden', !show);
});
if (curStatus !== 'all') {
const wrap = document.getElementById('list-wrap-' + curStatus);
if (wrap) {
wrap.classList.remove('collapsed');
const title = document.querySelector('[data-toggle="' + curStatus + '"] .s-arrow');
if (title) title.textContent = '▴ 收起';
}
}
}
document.querySelectorAll('[data-filter-level]').forEach(btn => {
btn.addEventListener('click', () => {
curLevel = btn.dataset.filterLevel;
document.querySelectorAll('[data-filter-level]').forEach(b => b.classList.toggle('active', b === btn));
applyFilter();
});
});
document.querySelectorAll('[data-filter-tier]').forEach(btn => {
btn.addEventListener('click', () => {
curTier = btn.dataset.filterTier;
document.querySelectorAll('[data-filter-tier]').forEach(b => b.classList.toggle('active', b === btn));
applyFilter();
});
});
document.querySelectorAll('[data-filter-status]').forEach(btn => {
btn.addEventListener('click', () => {
curStatus = btn.dataset.filterStatus;
document.querySelectorAll('[data-filter-status]').forEach(b => b.classList.toggle('active', b === btn));
applyFilter();
});
});
document.querySelectorAll('[data-filter-tag]').forEach(btn => {
btn.addEventListener('click', () => {
const t = btn.dataset.filterTag;
if (curTags.has(t)) { curTags.delete(t); btn.classList.remove('active'); }
else { curTags.add(t); btn.classList.add('active'); }
applyFilter();
});
});
document.querySelectorAll('.t-tag').forEach(tag => {
tag.addEventListener('click', e => {
e.stopPropagation();
const t = tag.dataset.tag;
const chip = document.querySelector('[data-filter-tag="'+t+'"]');
if (chip) chip.click();
});
});
document.querySelectorAll('.section-title[data-toggle]').forEach(title => {
title.addEventListener('click', () => {
const st = title.dataset.toggle;
const wrap = document.getElementById('list-wrap-' + st);
const arrow = title.querySelector('.s-arrow');
if (!wrap) return;
const collapsed = wrap.classList.toggle('collapsed');
if (arrow) arrow.textContent = collapsed ? '▾ 展开' : '▴ 收起';
});
});
// ── 拒绝验收 Modal ──
const modal = document.getElementById('reject-modal');
const reasonInput = document.getElementById('reject-reason-input');
const reasonErr = document.getElementById('reject-reason-err');
const cmdWrap = document.getElementById('reject-cmd-wrap');
const cmdText = document.getElementById('reject-cmd-text');
const copyBtn = document.getElementById('reject-copy-btn');
let rejectId = null;
document.querySelectorAll('.reject-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
rejectId = btn.dataset.id;
document.getElementById('reject-modal-subtitle').textContent = '#' + rejectId + ' ' + btn.dataset.title;
reasonInput.value = '';
reasonInput.classList.remove('error');
reasonErr.classList.remove('show');
cmdWrap.style.display = 'none';
document.getElementById('reject-confirm-btn').style.display = '';
copyBtn.textContent = '复制命令';
modal.style.display = 'flex';
setTimeout(() => reasonInput.focus(), 80);
});
});
document.getElementById('reject-cancel-btn').addEventListener('click', () => {
modal.style.display = 'none';
});
modal.addEventListener('click', e => {
if (e.target === modal) modal.style.display = 'none';
});
document.getElementById('reject-confirm-btn').addEventListener('click', () => {
const reason = reasonInput.value.trim();
if (!reason) {
reasonInput.classList.add('error');
reasonErr.classList.add('show');
reasonInput.focus();
return;
}
if (SERVED) {
postAction('/api/reject/' + rejectId, { reason })
.then(d => { if (d.ok) { location.reload(); } else { alert('拒绝失败:' + (d.error || '')); } })
.catch(() => alert('请求失败,确认本地服务是否在运行'));
return;
}
const cmd = '/todo reject ' + rejectId + ' ' + JSON.stringify(reason);
cmdText.textContent = cmd;
cmdWrap.style.display = 'block';
document.getElementById('reject-confirm-btn').style.display = 'none';
});
reasonInput.addEventListener('input', () => {
if (reasonInput.value.trim()) {
reasonInput.classList.remove('error');
reasonErr.classList.remove('show');
}
});
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(cmdText.textContent).then(() => {
copyBtn.textContent = '已复制 ✓';
});
});
// ── 确认闸:服务模式直接写库;file:// 模式回退为展开命令 ──
document.querySelectorAll('.approve-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const id = btn.dataset.id;
if (SERVED) {
btn.disabled = true; btn.textContent = '确认中…';
postAction('/api/approve/' + id)
.then(d => {
if (d.ok) { location.reload(); }
else { btn.disabled = false; btn.textContent = '确认执行 ✓'; alert('确认失败:' + (d.error || '')); }
})
.catch(() => { btn.disabled = false; btn.textContent = '确认执行 ✓'; alert('请求失败,确认本地服务是否在运行'); });
} else {
const box = document.getElementById('approve-cmd-' + id);
if (box) box.style.display = box.style.display === 'none' ? 'block' : 'none';
}
});
});
document.querySelectorAll('.approve-copy-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
navigator.clipboard.writeText(btn.dataset.cmd).then(() => { btn.textContent = '已复制 ✓'; });
});
});
})();
</script>
</body>
</html>
+54
View File
@@ -0,0 +1,54 @@
{
"meta": {
"title": "feature+windows — 项目 TODO",
"updated_at": "2026-06-22T09:32:56.170Z"
},
"seq": 3,
"items": [
{
"id": 1,
"title": "macOS 原生 KillSwitch 补齐 L0→L3",
"desc": "现状 L0(stub)VpnChannel.swift:69 setKillSwitch 直接 result(nil)、includeAllNetworks()→false。天花板 L3。改 NetworkExtensionincludeAllNetworks=true + NEOnDemandRule 常开 + enforceRoutes,由 neagent 系统级强制、扛 app 崩溃。性价比最高的跨端缺口。依据见 docs/killswitch-design.md §4-6。",
"level": "mid",
"tier": 2,
"tags": [
"mac"
],
"status": "open",
"created_at": "2026-06-22T09:32:42.340Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 2,
"title": "Android KillSwitch L1 + 引导系统 Always-on",
"desc": "现状 L0(stub)MainActivity.kt setKillSwitch 只打日志(TODO 11G)。目标:app 内做到 L1(config TUN strict_route,复刻 Windows 的 applyKillSwitchToConfig 语义) + 深链引导用户开系统设置「始终开启 VPN + 无 VPN 时阻止连接」拿 L3,UI 诚实标注「彻底防泄漏需在系统设置开启」。属 Android 客户端 11G。依据见 docs/killswitch-design.md §4-6。",
"level": "mid",
"tier": 2,
"tags": [
"Android"
],
"status": "open",
"created_at": "2026-06-22T09:32:56.098Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 3,
"title": "Windows KillSwitch L1→L2 (WFP 防火墙强制)",
"desc": "现状 L1desktop_vpn_bridge.dart strict_route + 子进程重载,进程被硬杀仍泄漏。目标 L2:app 装 WFP(Windows Filtering Platform) 过滤器阻断非隧道流量,做成系统服务持久化才能扛崩溃。工作量较大,backlog。依据见 docs/killswitch-design.md §4-6。",
"level": "low",
"tier": 1,
"tags": [
"Windows"
],
"status": "open",
"created_at": "2026-06-22T09:32:56.169Z",
"done": false,
"completed_at": null,
"version": null
}
]
}