Merge branch 'main' into feature/pangolin-ios
# Conflicts: # client/lib/bridge/vpn_bridge_provider.dart # docs/index.html
This commit is contained in:
+7
-3
@@ -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
|
||||||
|
|||||||
@@ -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_HOME,gomobile init 可能失败\n' >&2
|
printf '⚠ 未设置 ANDROID_NDK_HOME / ANDROID_HOME,gomobile init 可能失败\n' >&2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── 安装 gomobile ─────────────────────────────────────────────────────────────
|
# ── JDK(gomobile 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 自带 JBR(OpenJDK 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: %s(Android Studio JBR)\n' "${JAVA_HOME}"
|
||||||
|
else
|
||||||
|
printf '⚠ 未设 JAVA_HOME 且未找到 Android Studio JBR,gomobile bind 末段 javac 可能失败\n' >&2
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
printf ' JDK: %s(JAVA_HOME)\n' "${JAVA_HOME}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 安装 gomobile(SagerNet 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:linkname(Go 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}" \
|
||||||
|
|||||||
@@ -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 libbox(gomobile AAR)──────────────────────────────
|
// ── sing-box libbox(gomobile 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" />
|
||||||
|
|
||||||
|
|||||||
+124
@@ -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.registerNetworkCallback(API 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
|
||||||
|
|||||||
+327
-324
@@ -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 libbox,CommandServer 模型)
|
||||||
* 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)
|
// 内核启动必须在后台线程(不能阻塞 onStartCommand;openTun 回调会同步等 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. 构造 PlatformInterface(inner 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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── PlatformInterface(inner 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.Builder(inner 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) {
|
||||||
|
// DNS(1.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)
|
|
||||||
|
|
||||||
// ── 路由:将所有流量导入 TUN(sing-box auto_route = true)────
|
|
||||||
builder.addRoute("0.0.0.0", 0) // IPv4 全部流量
|
|
||||||
builder.addRoute("::", 0) // IPv6 全部流量
|
|
||||||
|
|
||||||
// ── DNS:sing-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>
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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/amd64,tag 含 <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. KillSwitch(11G)</b></td><td><code>setKillSwitch(on)</code> 做 <b>L1(<code>strict_route</code>)+ 引导系统 Always-on(L3)</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 X200(Android 16 / API 36,AOSP 系,主力真机)</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
@@ -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>
|
||||||
|
|||||||
@@ -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>WFP(Windows 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→L2(WFP 防火墙强制,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` libbox(gomobile)、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 JBR(OpenJDK 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 X200(Android 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.jar,javap 导出签名**
|
||||||
|
|
||||||
|
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 / 测试 → VpnBridgeMock;Android → 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 侧实现)。
|
||||||
|
- Modify(UI 文案): `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 X200(Android 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 编译链接 | 3–5 | debug APK 构建成功、analyze 零警告 |
|
||||||
|
| C 端到端连通 | 6–7 | 模拟器实测科学上网、UI 三态正确 |
|
||||||
|
| D 统计走字 | 8 | stats 每秒跳动且数值合理 |
|
||||||
|
| E 切节点 | 9 | 切出口不断连、当前出口正确 |
|
||||||
|
| F KillSwitch | 10 | strict_route 生效 + Always-on 引导 + 诚实文案 |
|
||||||
|
| 真机 | 11 | Vivo X200 全链路 + 华为兼容抽查 + 后台保活 |
|
||||||
+645
@@ -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。改 NetworkExtension:includeAllNetworks=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">现状 L1:desktop_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>
|
||||||
@@ -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。改 NetworkExtension:includeAllNetworks=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": "现状 L1:desktop_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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user