diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 41fa61c..f646972 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -6,7 +6,7 @@ plugins { id("org.jetbrains.kotlin.plugin.compose") } -val minSdk = 28 +val appMinSdk = 28 android { namespace = "net.bluemangoo.indpjsk" @@ -16,7 +16,7 @@ android { defaultConfig { applicationId = "net.bluemangoo.indpjsk" - minSdk = minSdk + minSdk = appMinSdk targetSdk = 37 versionCode = 1 versionName = "1.1" @@ -35,7 +35,29 @@ android { } buildFeatures { compose = true + buildConfig = true } + + sourceSets.getByName("main").assets.directories.add( + layout.buildDirectory.dir("generated/readme").get().asFile.absolutePath + ) +} + +val copyReadmeToAssets = tasks.register("copyReadmeToAssets") { + group = "build" + description = "Pack README.md" + from(rootProject.file("README.md")) + into(layout.buildDirectory.dir("generated/readme").get().asFile) + rename { "README.md" } + inputs.file(rootProject.file("README.md")).withPropertyName("readme") + outputs.dir(layout.buildDirectory.dir("generated/readme")).withPropertyName("readmeAssets") +} + +tasks.named("preBuild") { + dependsOn(copyReadmeToAssets) +} +tasks.matching { it.name.startsWith("merge") && it.name.contains("Assets") }.configureEach { + dependsOn(copyReadmeToAssets) } dependencies { @@ -87,7 +109,6 @@ fun resolveNdkDirectory(project: Project): File { throw GradleException("Failed to find any installed NDK versions in ${ndkParentDir.absolutePath}") } - // 取最新安装的版本 return installedVersions.last() } @@ -117,19 +138,19 @@ val cargoBuildVpnCore = tasks.register("cargoBuildVpnCore") { val execExt = if (OperatingSystem.current().isWindows) ".exe" else "" environment("NDK_HOME", ndkHomePath) - environment("PATH", "$ndkHomePath:${System.getenv("PATH")}") + environment("PATH", "${ndkBin.absolutePath}${File.pathSeparator}${System.getenv("PATH")}") environment( "CC_aarch64-linux-android", - ndkBin.resolve("aarch64-linux-android$minSdk-clang$scriptExt").absolutePath + ndkBin.resolve("aarch64-linux-android$appMinSdk-clang$scriptExt").absolutePath ) environment( "CXX_aarch64-linux-android", - ndkBin.resolve("aarch64-linux-android$minSdk-clang++$scriptExt").absolutePath + ndkBin.resolve("aarch64-linux-android$appMinSdk-clang++$scriptExt").absolutePath ) environment("AR_aarch64-linux-android", ndkBin.resolve("llvm-ar$execExt").absolutePath) environment( "CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER", - ndkBin.resolve("aarch64-linux-android$minSdk-clang$scriptExt").absolutePath + ndkBin.resolve("aarch64-linux-android$appMinSdk-clang$scriptExt").absolutePath ) } catch (e: Exception) { logger.warn("[cargoBuildVpnCore] resolveNdkDirectory failed: ${e.message}") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 30e24f2..a39cdc3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,9 @@ + AppScreen.PROXY + else -> AppScreen.MAIN + } + } + + when (screen) { + AppScreen.MAIN -> CaptureScreen( + viewModel = viewModel, + onStartCapture = { requestVpnAndStart() }, + onStopCapture = { viewModel.stopCapture(this) }, + onOpenProxySettings = { screen = AppScreen.PROXY }, + onOpenFiles = { screen = AppScreen.FILES }, + onOpenAbout = { screen = AppScreen.ABOUT }, + ) + + AppScreen.PROXY -> ProxySettingsScreen( + bypassPackages = bypassPackages, + onBypassChange = { newSet -> + bypassPackages = newSet + SettingsStore.saveBypassPackages(this, newSet) + }, + onSelectApps = { screen = AppScreen.APP_SELECTION }, + onBack = { screen = AppScreen.MAIN }, + ) + + AppScreen.APP_SELECTION -> AppSelectionScreen( + selected = bypassPackages, + onToggle = { entry -> + bypassPackages = if (entry.packageName in bypassPackages) { + bypassPackages - entry.packageName + } else { + bypassPackages + entry.packageName + } + SettingsStore.saveBypassPackages(this, bypassPackages) + }, + onBack = { screen = AppScreen.PROXY }, + ) + + AppScreen.FILES -> CapturedFilesScreen( + onOpenFile = { openCapturedFile(it) }, + onBack = { screen = AppScreen.MAIN }, + ) + + AppScreen.ABOUT -> AboutScreen( + readme = readme, + onBack = { screen = AppScreen.MAIN }, + ) + } } } } @@ -80,4 +153,4 @@ class MainActivity : ComponentActivity() { "html", "htm" -> "text/html" else -> "application/octet-stream" } -} \ No newline at end of file +} diff --git a/app/src/main/java/net/bluemangoo/indpjsk/data/CaptureFiles.kt b/app/src/main/java/net/bluemangoo/indpjsk/data/CaptureFiles.kt new file mode 100644 index 0000000..b094de9 --- /dev/null +++ b/app/src/main/java/net/bluemangoo/indpjsk/data/CaptureFiles.kt @@ -0,0 +1,24 @@ +package net.bluemangoo.indpjsk.data + +import android.content.Context +import java.io.File + +object CaptureFiles { + + fun dir(context: Context): File = File(context.filesDir, "captures") + + fun list(context: Context): List { + val root = dir(context) + if (!root.exists()) return emptyList() + return root.walkTopDown() + .filter { it.isFile } + .sortedByDescending { it.lastModified() } + .toList() + } + + fun clear(context: Context) { + val root = dir(context) + root.deleteRecursively() + root.mkdirs() + } +} diff --git a/app/src/main/java/net/bluemangoo/indpjsk/data/InstalledApps.kt b/app/src/main/java/net/bluemangoo/indpjsk/data/InstalledApps.kt new file mode 100644 index 0000000..939ac31 --- /dev/null +++ b/app/src/main/java/net/bluemangoo/indpjsk/data/InstalledApps.kt @@ -0,0 +1,46 @@ +package net.bluemangoo.indpjsk.data + +import android.content.Context +import android.content.pm.ApplicationInfo +import android.graphics.drawable.Drawable + +data class AppEntry( + val packageName: String, + val label: String, + val icon: Drawable, +) + +object InstalledApps { + + fun list(context: Context): List { + val pm = context.packageManager + return pm.getInstalledApplications(0) + .asSequence() + .filter { info -> + (info.flags and ApplicationInfo.FLAG_SYSTEM) == 0 || + (info.flags and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP) != 0 + } + .mapNotNull { info -> + runCatching { + AppEntry( + packageName = info.packageName, + label = pm.getApplicationLabel(info).toString(), + icon = pm.getApplicationIcon(info), + ) + }.getOrNull() + } + .distinctBy { it.packageName } + .sortedBy { it.label.lowercase() } + .toList() + } + + fun get(context: Context, packageName: String): AppEntry? = runCatching { + val pm = context.packageManager + val info = pm.getApplicationInfo(packageName, 0) + AppEntry( + packageName = packageName, + label = pm.getApplicationLabel(info).toString(), + icon = pm.getApplicationIcon(info), + ) + }.getOrNull() +} diff --git a/app/src/main/java/net/bluemangoo/indpjsk/data/SettingsStore.kt b/app/src/main/java/net/bluemangoo/indpjsk/data/SettingsStore.kt index 01a0fbb..4181959 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/data/SettingsStore.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/data/SettingsStore.kt @@ -3,21 +3,47 @@ package net.bluemangoo.indpjsk.data import android.content.Context import androidx.core.content.edit -/** 一些零碎的全局设置,暂时只有上传码 */ +/** 全局配置持久化:上传码、上游代理、忽略的应用、选中的配置 */ object SettingsStore { private const val PREFS = "settings" private const val KEY_UPLOAD_CODE = "upload_code" + private const val KEY_PROXY = "upstream_proxy" + private const val KEY_BYPASS = "bypass_packages" + private const val KEY_PROFILE = "selected_profile" - fun loadUploadCode(context: Context): String = + private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - .getString(KEY_UPLOAD_CODE, "") - .orEmpty() + + // ---- 上传码 ---- + fun loadUploadCode(context: Context): String = + prefs(context).getString(KEY_UPLOAD_CODE, "").orEmpty() fun saveUploadCode(context: Context, code: String) { - context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - .edit { - putString(KEY_UPLOAD_CODE, code) - } + prefs(context).edit { putString(KEY_UPLOAD_CODE, code) } + } + + // ---- 上游代理(空 = 直连) ---- + fun loadProxy(context: Context): String = + prefs(context).getString(KEY_PROXY, "").orEmpty() + + fun saveProxy(context: Context, proxy: String) { + prefs(context).edit { putString(KEY_PROXY, proxy.trim()) } + } + + // ---- 不走 VPN 的应用(包名) ---- + fun loadBypassPackages(context: Context): Set = + prefs(context).getStringSet(KEY_BYPASS, emptySet())?.toSet() ?: emptySet() + + fun saveBypassPackages(context: Context, packages: Set) { + prefs(context).edit { putStringSet(KEY_BYPASS, packages) } + } + + // ---- 当前选中的配置 ---- + fun loadSelectedProfileId(context: Context): String? = + prefs(context).getString(KEY_PROFILE, null) + + fun saveSelectedProfileId(context: Context, id: String) { + prefs(context).edit { putString(KEY_PROFILE, id) } } } diff --git a/app/src/main/java/net/bluemangoo/indpjsk/ui/AboutScreen.kt b/app/src/main/java/net/bluemangoo/indpjsk/ui/AboutScreen.kt new file mode 100644 index 0000000..2048595 --- /dev/null +++ b/app/src/main/java/net/bluemangoo/indpjsk/ui/AboutScreen.kt @@ -0,0 +1,67 @@ +package net.bluemangoo.indpjsk.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import net.bluemangoo.indpjsk.BuildConfig +import net.bluemangoo.indpjsk.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AboutScreen( + readme: String, + onBack: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text("关于") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(R.drawable.arrow_back_24px), + contentDescription = "返回" + ) + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Text( + text = "Version ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})", + style = MaterialTheme.typography.titleMedium + ) + Spacer(Modifier.height(8.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + Text( + text = readme, + style = MaterialTheme.typography.bodySmall, + softWrap = true + ) + } + } +} diff --git a/app/src/main/java/net/bluemangoo/indpjsk/ui/AppIcon.kt b/app/src/main/java/net/bluemangoo/indpjsk/ui/AppIcon.kt new file mode 100644 index 0000000..57948e9 --- /dev/null +++ b/app/src/main/java/net/bluemangoo/indpjsk/ui/AppIcon.kt @@ -0,0 +1,25 @@ +package net.bluemangoo.indpjsk.ui + +import android.graphics.drawable.Drawable +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.toBitmap + +@Composable +fun AppIcon(icon: Drawable, label: String?, size: Dp = 40.dp) { + val image = remember(icon) { + runCatching { icon.toBitmap(96, 96) }.getOrNull()?.asImageBitmap() + } + if (image != null) { + Image(bitmap = image, contentDescription = label, modifier = Modifier.size(size)) + } else { + Box(modifier = Modifier.size(size)) + } +} diff --git a/app/src/main/java/net/bluemangoo/indpjsk/ui/AppSelectionScreen.kt b/app/src/main/java/net/bluemangoo/indpjsk/ui/AppSelectionScreen.kt new file mode 100644 index 0000000..54180a9 --- /dev/null +++ b/app/src/main/java/net/bluemangoo/indpjsk/ui/AppSelectionScreen.kt @@ -0,0 +1,168 @@ +package net.bluemangoo.indpjsk.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.bluemangoo.indpjsk.R +import net.bluemangoo.indpjsk.data.AppEntry +import net.bluemangoo.indpjsk.data.InstalledApps + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppSelectionScreen( + selected: Set, + onToggle: (AppEntry) -> Unit, + onBack: () -> Unit, +) { + val context = LocalContext.current + var allApps by remember { mutableStateOf>(emptyList()) } + var loading by remember { mutableStateOf(true) } + var query by remember { mutableStateOf("") } + + LaunchedEffect(Unit) { + val list = withContext(Dispatchers.IO) { InstalledApps.list(context) } + allApps = list + loading = false + } + + val filtered = remember(query, allApps) { + if (query.isBlank()) { + allApps + } else { + allApps.filter { + it.label.contains(query, ignoreCase = true) || + it.packageName.contains(query, ignoreCase = true) + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("选择忽略的应用") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(R.drawable.arrow_back_24px), + contentDescription = "返回" + ) + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .fillMaxSize() + ) { + OutlinedTextField( + value = query, + onValueChange = { query = it }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + placeholder = { Text("搜索应用名称或包名…") }, + singleLine = true + ) + + if (loading) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else if (filtered.isEmpty()) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Text( + "没有找到相关的应用", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + LazyColumn( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + items(filtered, key = { it.packageName }) { app -> + val checked = app.packageName in selected + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onToggle(app) } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AppIcon(icon = app.icon, label = app.label) + Spacer(modifier = Modifier.width(16.dp)) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = app.label, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = app.packageName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Checkbox( + checked = checked, + onCheckedChange = { onToggle(app) } + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/net/bluemangoo/indpjsk/ui/CaptureScreen.kt b/app/src/main/java/net/bluemangoo/indpjsk/ui/CaptureScreen.kt index 289890b..c800b07 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/ui/CaptureScreen.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/ui/CaptureScreen.kt @@ -1,43 +1,13 @@ package net.bluemangoo.indpjsk.ui import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Checkbox -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -58,7 +28,6 @@ import net.bluemangoo.indpjsk.state.CaptureStatus import net.bluemangoo.indpjsk.vpn.LogEntry import net.bluemangoo.indpjsk.vpn.LogLevel import net.bluemangoo.indpjsk.vpn.VpnLog -import java.io.File @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -66,15 +35,18 @@ fun CaptureScreen( viewModel: CaptureViewModel, onStartCapture: () -> Unit, onStopCapture: () -> Unit, - onOpenFile: (File) -> Unit + onOpenProxySettings: () -> Unit, + onOpenFiles: () -> Unit, + onOpenAbout: () -> Unit, ) { val state by viewModel.uiState.collectAsState() val logs by VpnLog.logs.collectAsState() - var dropdownExpanded by remember { mutableStateOf(false) } val context = LocalContext.current var enabledMap by remember { mutableStateOf(StepSelectionStore.loadAll(context) as Map) } - var uploadCode by remember { mutableStateOf(SettingsStore.loadUploadCode(context)) } + var menuExpanded by remember { mutableStateOf(false) } + var profileExpanded by remember { mutableStateOf(false) } + var showUploadCodeDialog by remember { mutableStateOf(false) } val isRunning = state.status == CaptureStatus.RUNNING val isCaptured = state.status == CaptureStatus.CAPTURED @@ -85,40 +57,52 @@ fun CaptureScreen( TopAppBar( title = { Text("咖喱烧烤无ROOT抓包") }, actions = { - IconButton(onClick = { VpnLog.clear() }) { - Icon( - painter = painterResource(id = R.drawable.delete_24px), - contentDescription = "清空日志" - ) - } Box { - TextButton( - onClick = { dropdownExpanded = true }, - enabled = !isRunning - ) { - Text( - text = selected?.name ?: "无配置", - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + IconButton(onClick = { menuExpanded = true }) { Icon( - painter = painterResource(id = R.drawable.arrow_drop_down_24px), - contentDescription = null + painter = painterResource(id = R.drawable.more_vert_24px), + contentDescription = "菜单" ) } DropdownMenu( - expanded = dropdownExpanded, - onDismissRequest = { dropdownExpanded = false } + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false } ) { - state.profiles.forEach { profile -> - DropdownMenuItem( - text = { Text(profile.name) }, - onClick = { - viewModel.selectProfile(profile) - dropdownExpanded = false - } - ) - } + DropdownMenuItem( + text = { Text("上传码") }, + onClick = { + menuExpanded = false + showUploadCodeDialog = true + } + ) + DropdownMenuItem( + text = { Text("代理设置") }, + onClick = { + menuExpanded = false + onOpenProxySettings() + } + ) + DropdownMenuItem( + text = { Text("已抓文件") }, + onClick = { + menuExpanded = false + onOpenFiles() + } + ) + DropdownMenuItem( + text = { Text("清空日志") }, + onClick = { + menuExpanded = false + VpnLog.clear() + } + ) + DropdownMenuItem( + text = { Text("关于") }, + onClick = { + menuExpanded = false + onOpenAbout() + } + ) } } } @@ -132,19 +116,40 @@ fun CaptureScreen( .padding(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - OutlinedTextField( - value = uploadCode, - onValueChange = { - uploadCode = it.trim() - SettingsStore.saveUploadCode(context, uploadCode) - }, - modifier = Modifier.fillMaxWidth(), - enabled = !isRunning, - singleLine = true, - label = { Text("上传码(从 Haruki 工具箱 - 数据上传 - iOS 获取)") }, - textStyle = MaterialTheme.typography.bodyMedium, - visualTransformation = PasswordVisualTransformation() - ) + Box(modifier = Modifier.fillMaxWidth()) { + OutlinedButton( + onClick = { profileExpanded = true }, + enabled = !isRunning, + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + ) { + Text( + text = "配置:${selected?.name ?: "未选择"}", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Icon( + painter = painterResource(id = R.drawable.arrow_drop_down_24px), + contentDescription = null + ) + } + DropdownMenu( + expanded = profileExpanded, + onDismissRequest = { profileExpanded = false } + ) { + state.profiles.forEach { profile -> + DropdownMenuItem( + text = { Text(profile.name) }, + onClick = { + viewModel.selectProfile(context, profile) + profileExpanded = false + } + ) + } + } + } if (selected != null) { StepSelector( @@ -191,7 +196,11 @@ fun CaptureScreen( } OutlinedButton( - onClick = { state.lastCapturedFile?.let(onOpenFile) }, + onClick = { + if (state.lastCapturedFile != null) { + onOpenFiles() + } + }, enabled = isCaptured && state.lastCapturedFile != null, modifier = Modifier .fillMaxWidth() @@ -206,6 +215,48 @@ fun CaptureScreen( } } } + + if (showUploadCodeDialog) { + UploadCodeDialog(onDismiss = { showUploadCodeDialog = false }) + } +} + +@Composable +private fun UploadCodeDialog(onDismiss: () -> Unit) { + val context = LocalContext.current + var text by remember { mutableStateOf(SettingsStore.loadUploadCode(context)) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("上传码") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = text, + onValueChange = { text = it.trim() }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text("Haruki 上传码") }, + visualTransformation = PasswordVisualTransformation(), + textStyle = MaterialTheme.typography.bodyMedium + ) + Text( + text = "从 Haruki 工具箱 - 数据上传 - iOS 获取。", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + confirmButton = { + TextButton(onClick = { + SettingsStore.saveUploadCode(context, text) + onDismiss() + }) { Text("保存") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("取消") } + } + ) } @Composable @@ -328,4 +379,4 @@ private fun StatusLine(status: CaptureStatus, error: String?) { fontWeight = FontWeight.Medium ) HorizontalDivider() -} \ No newline at end of file +} diff --git a/app/src/main/java/net/bluemangoo/indpjsk/ui/CaptureViewModel.kt b/app/src/main/java/net/bluemangoo/indpjsk/ui/CaptureViewModel.kt index 06a1ddb..e9d4083 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/ui/CaptureViewModel.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/ui/CaptureViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import net.bluemangoo.indpjsk.data.CaptureProfile import net.bluemangoo.indpjsk.data.PredefinedProfiles +import net.bluemangoo.indpjsk.data.SettingsStore import net.bluemangoo.indpjsk.state.CaptureStateHolder import net.bluemangoo.indpjsk.state.CaptureStatus import net.bluemangoo.indpjsk.vpn.CaptureVpnService @@ -45,8 +46,9 @@ class CaptureViewModel : ViewModel() { ) ) - fun selectProfile(profile: CaptureProfile) { + fun selectProfile(context: Context, profile: CaptureProfile) { CaptureStateHolder.selectProfile(profile) + SettingsStore.saveSelectedProfileId(context, profile.id) } fun startCapture(context: Context) { diff --git a/app/src/main/java/net/bluemangoo/indpjsk/ui/CapturedFilesScreen.kt b/app/src/main/java/net/bluemangoo/indpjsk/ui/CapturedFilesScreen.kt new file mode 100644 index 0000000..729e06a --- /dev/null +++ b/app/src/main/java/net/bluemangoo/indpjsk/ui/CapturedFilesScreen.kt @@ -0,0 +1,135 @@ +package net.bluemangoo.indpjsk.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import net.bluemangoo.indpjsk.R +import net.bluemangoo.indpjsk.data.CaptureFiles +import java.io.File + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CapturedFilesScreen( + onOpenFile: (File) -> Unit, + onBack: () -> Unit, +) { + val context = LocalContext.current + var version by remember { mutableIntStateOf(0) } + val files = remember(version) { CaptureFiles.list(context) } + var confirmClear by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("获取到的文件") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(R.drawable.arrow_back_24px), + contentDescription = "返回" + ) + } + }, + actions = { + IconButton( + onClick = { confirmClear = true }, + enabled = files.isNotEmpty() + ) { + Icon( + painter = painterResource(R.drawable.delete_24px), + contentDescription = "清空" + ) + } + } + ) + } + ) { padding -> + if (files.isEmpty()) { + Box( + modifier = Modifier + .padding(padding) + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + "还没有获取到文件", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + LazyColumn( + modifier = Modifier + .padding(padding) + .fillMaxSize() + ) { + items(files, key = { it.absolutePath }) { file -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onOpenFile(file) } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.file_open_24px), + contentDescription = null + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = file.name, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + } + + if (confirmClear) { + AlertDialog( + onDismissRequest = { confirmClear = false }, + title = { Text("清空文件") }, + text = { Text("删除全部已获取的文件?") }, + confirmButton = { + TextButton(onClick = { + CaptureFiles.clear(context) + version++ + confirmClear = false + }) { Text("删除") } + }, + dismissButton = { + TextButton(onClick = { confirmClear = false }) { Text("取消") } + } + ) + } +} diff --git a/app/src/main/java/net/bluemangoo/indpjsk/ui/ProxySettingsScreen.kt b/app/src/main/java/net/bluemangoo/indpjsk/ui/ProxySettingsScreen.kt new file mode 100644 index 0000000..6bd2377 --- /dev/null +++ b/app/src/main/java/net/bluemangoo/indpjsk/ui/ProxySettingsScreen.kt @@ -0,0 +1,183 @@ +package net.bluemangoo.indpjsk.ui + +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import net.bluemangoo.indpjsk.R +import net.bluemangoo.indpjsk.data.AppEntry +import net.bluemangoo.indpjsk.data.InstalledApps +import net.bluemangoo.indpjsk.data.SettingsStore + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProxySettingsScreen( + bypassPackages: Set, + onBypassChange: (Set) -> Unit, + onSelectApps: () -> Unit, + onBack: () -> Unit, +) { + val context = LocalContext.current + var proxy by remember { mutableStateOf(SettingsStore.loadProxy(context)) } + var pendingRemove by remember { mutableStateOf(null) } + + val entries = remember(bypassPackages) { + bypassPackages.sorted().mapNotNull { InstalledApps.get(context, it) } + } + + val looksValid = proxy.isBlank() || + ((proxy.startsWith("http://") || proxy.startsWith("https://")) && proxy.contains(":")) + + Scaffold( + topBar = { + TopAppBar( + title = { Text("代理设置") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(R.drawable.arrow_back_24px), + contentDescription = "返回" + ) + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 0.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + + Text("上游代理", style = MaterialTheme.typography.labelLarge) + OutlinedTextField( + value = proxy, + onValueChange = { + proxy = it + SettingsStore.saveProxy(context, it) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text("上游代理地址") }, + placeholder = { Text("http://127.0.0.1:7890") }, + isError = !looksValid, + supportingText = { + Text( + text = if (looksValid) { + "留空以忽略;只支持 http / https 代理" + } else { + "格式示例:http://127.0.0.1:7890" + } + ) + } + ) + + HorizontalDivider() + + Text("绕过本应用代理的应用", style = MaterialTheme.typography.labelLarge) + } + + Card( + onClick = onSelectApps, + shape = RectangleShape, + colors = CardDefaults.cardColors( + containerColor = Color.Transparent + ), + modifier = Modifier + .fillMaxWidth() + ) { + Text( + text = "选择应用(${bypassPackages.size})", + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) + ) + } + + if (entries.isEmpty()) { + Text( + text = "列表为空", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 0.dp) + ) + } else { + entries.forEach { entry -> + BypassAppRow(entry = entry, onLongPress = { pendingRemove = entry }) + } + } + } + } + + pendingRemove?.let { entry -> + AlertDialog( + onDismissRequest = { pendingRemove = null }, + title = { Text("移除应用") }, + text = { Text("从应用列表移除「${entry.label}」?") }, + confirmButton = { + TextButton(onClick = { + onBypassChange(bypassPackages - entry.packageName) + pendingRemove = null + }) { Text("移除") } + }, + dismissButton = { + TextButton(onClick = { pendingRemove = null }) { Text("取消") } + } + ) + } +} + +@Composable +private fun BypassAppRow(entry: AppEntry, onLongPress: () -> Unit) { + Card( + shape = RectangleShape, + colors = CardDefaults.cardColors( + containerColor = Color.Transparent + ), + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = {}, + onLongClick = onLongPress + ) + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + + AppIcon(icon = entry.icon, label = entry.label, size = 32.dp) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = entry.label, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = entry.packageName, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} diff --git a/app/src/main/java/net/bluemangoo/indpjsk/vpn/CaptureVpnService.kt b/app/src/main/java/net/bluemangoo/indpjsk/vpn/CaptureVpnService.kt index 585530a..461ba1c 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/vpn/CaptureVpnService.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/vpn/CaptureVpnService.kt @@ -19,20 +19,6 @@ import net.bluemangoo.indpjsk.state.CaptureStateHolder import net.bluemangoo.indpjsk.upload.Uploader import java.io.File -/** - * 多步骤抓包: - * establish() 只做一次,同一个 tun fd 按顺序喂给每一步,中间不拆 VPN。 - * 每一步抓到后异步跑它的 postAction(不阻塞下一步的监听)。 - * - * 停止机制: - * ACTION_STOP → requestStop() + close(activePfd),当前步骤的 runCapture 返回 null, - * 循环发现 stopRequested 后终止后续步骤。 - * - * fd 所有权: - * 全生命周期在 Android 侧持有。传给 Rust 的是 pfd.fd(int 缓存值), - * Rust 内部用 BorrowedAsyncDevice 借用,不做任何 close。 - * Android 在 finally 里统一 close(activePfd)。 - */ @SuppressLint("VpnServicePolicy") class CaptureVpnService : VpnService() { @@ -44,14 +30,8 @@ class CaptureVpnService : VpnService() { private const val VPN_ADDRESS = "10.7.0.2" private const val VPN_PREFIX = 32 - - /** - * 后处理(上传)用的 scope:进程级,故意不随 Service 销毁取消。 - * 否则 stopSelf() → onDestroy 会把还在上传的分片掐掉(Job was cancelled)。 - */ private val postScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - /** 捕获文件统一放在 filesDir/captures// 下(file_paths.xml 的 FileProvider 路径) */ fun captureFile( filesDir: File, profile: CaptureProfile, @@ -59,14 +39,12 @@ class CaptureVpnService : VpnService() { ): File = File(File(File(filesDir, "captures"), profile.id), step.fileName) } - /** 工作线程持有;供 ACTION_STOP 从主线程关闭以中断 Rust */ @Volatile private var activePfd: ParcelFileDescriptor? = null @Volatile private var captureThread: Thread? = null - /** 区分"用户主动停止"与"抓到包后自然结束/失败" */ @Volatile private var stopRequested = false @@ -84,7 +62,6 @@ class CaptureVpnService : VpnService() { VpnLog.warn("手动停止…") VpnBridge.requestStop() closeActivePfd() - // 立即把 UI 从 RUNNING 拉回来;capture 线程退出时还会再写一次 CaptureStateHolder.setIdle() stopSelf() } @@ -111,7 +88,6 @@ class CaptureVpnService : VpnService() { return } - // 按 profile 里的顺序,过滤掉没勾选的步骤(默认全勾) val savedSelection = StepSelectionStore.loadAll(this) val enabledSteps = profile.steps.filter { step -> StepSelectionStore.isEnabled(savedSelection, profile.id, step.id) @@ -122,12 +98,18 @@ class CaptureVpnService : VpnService() { return } - // 上传码在开始时快照,运行中改 UI 不影响本次 val uploadCode = SettingsStore.loadUploadCode(this).trim() if (uploadCode.isNotEmpty() && uploadCode.length != 32) { VpnLog.warn("上传码长度不是 32 位(当前 ${uploadCode.length} 位),上传大概率会失败") } + val proxy = SettingsStore.loadProxy(this).trim() + val bypassPackages = SettingsStore.loadBypassPackages(this) + if (proxy.isNotEmpty()) VpnLog.info("上游代理: $proxy") + if (bypassPackages.isNotEmpty()) { + VpnLog.info("忽略的应用: ${bypassPackages.joinToString(", ")}") + } + stopRequested = false VpnLog.info("==== 开始抓包 ====") VpnLog.info("配置: ${profile.name}") @@ -144,11 +126,10 @@ class CaptureVpnService : VpnService() { throw IllegalStateException("Rust 动态库 core_vpn 未加载") } - // 2) 建立 VPN:仅 /32 目标路由 + 排除自身(防止 Rust 转发流量回环) val builder = Builder() .setSession("Capture-${profile.id}") .addAddress(VPN_ADDRESS, VPN_PREFIX) - .addDisallowedApplication(packageName)// 1.0.0.0/8 (排除 0.0.0.0/8 本地识别地址) + .addDisallowedApplication(packageName) .addRoute("1.0.0.0", 8) .addRoute("2.0.0.0", 7) @@ -207,6 +188,12 @@ class CaptureVpnService : VpnService() { .addDnsServer("8.8.8.8") .addDnsServer("114.114.114.114") + for (pkg in bypassPackages) { + if (pkg == packageName) continue + runCatching { builder.addDisallowedApplication(pkg) } + .onFailure { VpnLog.warn("忽略应用 $pkg 失败: ${it.message}") } + } + pfd = builder.establish() ?: throw IllegalStateException("VpnService.establish() 返回 null") activePfd = pfd @@ -224,12 +211,12 @@ class CaptureVpnService : VpnService() { target.parentFile?.mkdirs() if (target.exists()) target.delete() - // 阻塞调用 Rust,直到抓到本步骤的目标请求(或停止 / 出错) val capturedUrl = VpnBridge.runCapture( fd, step.hosts.joinToString(","), step.path, - target.absolutePath + target.absolutePath, + proxy ) if (capturedUrl == null) { @@ -251,7 +238,6 @@ class CaptureVpnService : VpnService() { completed++ VpnLog.success("$label 抓取成功") - // 抓到后异步做后处理(上传 / 该步骤自己的 postAction),不阻塞下一步监听 val result = CaptureResult(profile, step, capturedUrl, target) postScope.launch { try { @@ -274,7 +260,6 @@ class CaptureVpnService : VpnService() { errorMsg = t.message ?: "抓包异常" VpnLog.error("抓包异常: ${t.message}") } finally { - // 关闭 fd(若 ACTION_STOP 已关,这次 close 是幂等的/会抛可忽略异常) closeActivePfd() VpnLog.info("==== 抓包结束($completed/${enabledSteps.size} 步完成)====") diff --git a/app/src/main/java/net/bluemangoo/indpjsk/vpn/VpnBridge.kt b/app/src/main/java/net/bluemangoo/indpjsk/vpn/VpnBridge.kt index 4332901..9bb7292 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/vpn/VpnBridge.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/vpn/VpnBridge.kt @@ -6,10 +6,11 @@ import android.util.Log * Rust core_vpn 的 JNI 桥。 * * 契约(由 Rust lib.rs 实现): - * runCapture(jint tunFd, jstring targetHosts, jstring path, jstring savePath) -> String? + * runCapture(jint tunFd, jstring targetHosts, jstring path, jstring savePath, jstring proxy) -> String? * - 阻塞直到抓包结束 * - tunFd 的所有权【仍在 Kotlin 侧】,Rust 内部用 BorrowedAsyncDevice 借用,不得 close() * - targetHosts 是逗号分隔的域名白名单(一个步骤可能对应多个上游域名) + * - proxy 可为 null / 空串(直连),否则必须是 http(s)://host:port 形式,Rust 侧会再校验一次 * - 返回非 null = 抓到目标请求,值是命中的原始完整 URL(https://host/path?query),文件已写入 savePath * - 返回 null = 失败 / 被 requestStop 打断 / 未匹配 * @@ -42,7 +43,8 @@ object VpnBridge { tunFd: Int, targetHosts: String, targetPath: String, - savePath: String + savePath: String, + proxy: String? ): String? @JvmStatic diff --git a/app/src/main/res/drawable/arrow_back_24px.xml b/app/src/main/res/drawable/arrow_back_24px.xml new file mode 100644 index 0000000..800941d --- /dev/null +++ b/app/src/main/res/drawable/arrow_back_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/more_vert_24px.xml b/app/src/main/res/drawable/more_vert_24px.xml new file mode 100644 index 0000000..59400ec --- /dev/null +++ b/app/src/main/res/drawable/more_vert_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/core_vpn/Cargo.lock b/core_vpn/Cargo.lock index 72d96bc..416c2e7 100644 --- a/core_vpn/Cargo.lock +++ b/core_vpn/Cargo.lock @@ -61,7 +61,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "synstructure", + "synstructure 0.13.2", ] [[package]] @@ -264,6 +264,7 @@ name = "core_vpn" version = "0.1.0" dependencies = [ "anyhow", + "base64", "bytes", "http", "http-body-util", @@ -281,6 +282,7 @@ dependencies = [ "tokio-rustls", "tokio-util", "tun-rs", + "url", "webpki-roots", ] @@ -422,6 +424,15 @@ dependencies = [ "spin", ] +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -595,6 +606,110 @@ dependencies = [ "tracing", ] +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "ipnet" version = "2.12.2" @@ -713,6 +828,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "lock_api" version = "0.4.14" @@ -1003,6 +1124,15 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.1" @@ -1379,6 +1509,12 @@ dependencies = [ "lock_api", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "subtle" version = "2.6.1" @@ -1418,6 +1554,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "system-configuration-sys" version = "0.6.0" @@ -1478,6 +1625,16 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tokio" version = "1.54.1" @@ -1597,6 +1754,24 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "version_check" version = "0.9.5" @@ -1921,6 +2096,12 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + [[package]] name = "x509-parser" version = "0.18.1" @@ -1949,6 +2130,29 @@ dependencies = [ "time", ] +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure 0.14.0", +] + [[package]] name = "zerocopy" version = "0.8.63" @@ -1969,12 +2173,66 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure 0.14.0", +] + [[package]] name = "zeroize" version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/core_vpn/Cargo.toml b/core_vpn/Cargo.toml index 3982887..3be2cee 100644 --- a/core_vpn/Cargo.toml +++ b/core_vpn/Cargo.toml @@ -26,6 +26,8 @@ hyper-util = { version = "0.1", features = ["tokio", "server", "client"] } http-body-util = "0.1" http = "1.5.0" qstring = "0.7.2" +url = "2" +base64 = "0.23.1" [build-dependencies] rcgen = { version = "0.14", features = ["x509-parser"] } diff --git a/core_vpn/src/lib.rs b/core_vpn/src/lib.rs index 2b334d0..f5efbc7 100644 --- a/core_vpn/src/lib.rs +++ b/core_vpn/src/lib.rs @@ -3,7 +3,7 @@ mod mitm; mod tun; mod mut_qstring; -use crate::mitm::run_capture_async; +use crate::mitm::{ProxyConfig, run_capture_async}; use jni::objects::{Global, JClass, JObject, JString}; use jni::sys::{jint, jstring}; use jni::errors::ThrowRuntimeExAndDefault; @@ -89,6 +89,7 @@ pub extern "system" fn Java_net_bluemangoo_indpjsk_vpn_VpnBridge_runCapture( target_host: JString, target_path: JString, save_path: JString, + proxy: JString, ) -> jstring { let _ = env.with_env(|env: &mut Env| -> Result<(), jni::errors::Error> { init_jvm(env); @@ -118,13 +119,30 @@ pub extern "system" fn Java_net_bluemangoo_indpjsk_vpn_VpnBridge_runCapture( } }; + let proxy_raw: String = if proxy.is_null() { + String::new() + } else { + proxy.to_string() + }; + let proxy_cfg = match ProxyConfig::parse(&proxy_raw) { + Ok(cfg) => cfg, + Err(e) => { + error!("代理配置无效: {e}"); + return std::ptr::null_mut(); + } + }; + if let Some(cfg) = &proxy_cfg { + info!("upstream proxy: {}://{}:{}", if cfg.tls { "https" } else { "http" }, cfg.host, cfg.port); + } + let rt = tokio::runtime::Runtime::new().unwrap(); let cancel_token = CancellationToken::new(); *CANCLE_TOKEN.write().unwrap() = Some(cancel_token.clone()); let cancel_token_clone = cancel_token.clone(); - let result = - rt.block_on(async move { run_capture_async(tun_fd, hosts, re, out, cancel_token_clone).await }); + let result = rt.block_on(async move { + run_capture_async(tun_fd, hosts, re, out, proxy_cfg, cancel_token_clone).await + }); cancel_token.cancel(); rt.shutdown_timeout(std::time::Duration::from_secs(2)); diff --git a/core_vpn/src/mitm.rs b/core_vpn/src/mitm.rs index 0f9538a..5f9bbb7 100644 --- a/core_vpn/src/mitm.rs +++ b/core_vpn/src/mitm.rs @@ -14,6 +14,7 @@ use std::net::SocketAddr; use std::os::fd::RawFd; use std::sync::{Arc, OnceLock}; use std::time::Duration; +use http::Uri; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tokio::net::UdpSocket; use tokio::sync::Mutex; @@ -112,6 +113,7 @@ where async fn handle_tls_connection( tcp: S, dst: SocketAddr, + upstream: Upstream, target_hosts: Vec, target_path: Regex, save_path: String, @@ -134,7 +136,8 @@ where let Some((sni, accepted)) = sniffed else { io.rewind(); io.stop_recording(); - return passthrough_cancel_wrapped(io, dst, &cancel).await; + // 非 TLS:拿不到 SNI,走代理时只能按 IP CONNECT + return passthrough_cancel_wrapped(io, dst, None, upstream, &cancel).await; }; let host_matched = sni @@ -145,7 +148,7 @@ where if !host_matched { io.rewind(); io.stop_recording(); - let r = passthrough_cancel_wrapped(io, dst, &cancel).await; + let r = passthrough_cancel_wrapped(io, dst, sni.as_deref(), upstream, &cancel).await; if r.is_err() { warn!("tcp_passthrough error on {sni:?} {dst:?}"); } @@ -159,14 +162,16 @@ where .await?; let svc_host = sni.clone().unwrap_or_default(); + let svc_upstream = upstream; let svc = hyper::service::service_fn(move |req: hyper::Request| { let host = svc_host.clone(); let path = target_path.clone(); let save = save_path.clone(); let result_tx = result_tx.clone(); + let upstream = svc_upstream.clone(); async move { Ok::<_, std::convert::Infallible>( - forward_request(req, dst, host, path, save, result_tx).await, + forward_request(req, dst, host, path, save, result_tx, upstream).await, ) } }); @@ -199,6 +204,8 @@ where async fn passthrough_cancel_wrapped( io: IO, dst: SocketAddr, + sni: Option<&str>, + upstream: Upstream, cancel: &CancellationToken, ) -> anyhow::Result<()> where @@ -206,7 +213,7 @@ where { tokio::select! { _ = cancel.cancelled() => Ok(()), - r = tcp_passthrough(io, dst) => r, + r = tcp_passthrough(io, dst, sni, upstream) => r, } } @@ -218,8 +225,9 @@ async fn forward_request( target_path: Regex, save_path: String, result_tx: Arc>>>, + upstream: Upstream, ) -> hyper::Response> { - match forward_request_inner(req, dst, &url_host, &target_path).await { + match forward_request_inner(req, dst, &url_host, &target_path, &upstream).await { Ok((response, matched_body)) => { if let Some((url, body)) = matched_body { info!("Target matched! Downloading {} bytes", body.len()); @@ -257,6 +265,7 @@ async fn forward_request_inner( dst: SocketAddr, url_host: &str, target_path: &Regex, + upstream: &Upstream, ) -> anyhow::Result<(hyper::Response>, Option<(String, Bytes)>)> { let (mut parts, body) = req.into_parts(); @@ -293,7 +302,7 @@ async fn forward_request_inner( client_config.alpn_protocols = vec![b"http/1.1".to_vec()]; let connector = tokio_rustls::TlsConnector::from(Arc::new(client_config)); let server_name = rustls::pki_types::ServerName::try_from(url_host.to_string())?; - let tcp_to_server = tokio::net::TcpStream::connect(dst).await?; + let tcp_to_server = upstream.connect(dst, Some(url_host)).await?; let server_tls = connector.connect(server_name, tcp_to_server).await?; let (mut sender, conn) = @@ -335,7 +344,7 @@ async fn forward_request_inner( )) } -fn build_url(host: &str, uri: &http::Uri) -> String { +fn build_url(host: &str, uri: &Uri) -> String { if uri.scheme().is_some() && uri.authority().is_some() { return uri.to_string(); } @@ -343,15 +352,155 @@ fn build_url(host: &str, uri: &http::Uri) -> String { format!("https://{host}{pq}") } -fn force_full_mysekai(uri: &http::Uri) -> Option { - if let q = Some(uri.query().is_some()) { - let mut query = qstring::QString::from(q); - if query.has("isForceAllReloadOnlyMysekai") { - query.set("isForceAllReloadOnlyMysekai", "true"); - return Some(http::Uri::try_from(query.to_string().as_str()).ok()?); +/// 把 MySekai 的增量刷新标志强制改成 True(和模块的 URL Rewrite 一致), +/// 其它 query 参数原样保留。没有这个参数、或已经是 True 时返回 None。 +fn force_full_mysekai(uri: &Uri) -> Option { + const KEY: &str = "isForceAllReloadOnlyMysekai"; + let mut query = qstring::QString::from(uri.query()?); + if !query.has(KEY) || query.get(KEY) == Some("True") { + return None; + } + query.set(KEY, "True"); + Uri::try_from(format!("{}?{}", uri.path(), query).as_str()).ok() +} + + +#[derive(Clone, Debug)] +pub struct ProxyConfig { + pub tls: bool, + pub host: String, + pub port: u16, + pub auth: Option, +} + +impl ProxyConfig { + pub fn parse(input: &str) -> anyhow::Result> { + let raw = input.trim(); + if raw.is_empty() { + return Ok(None); + } + let url = url::Url::parse(raw) + .map_err(|e| anyhow::anyhow!("代理地址不是合法 URL: {raw} ({e})"))?; + let tls = match url.scheme() { + "http" => false, + "https" => true, + other => anyhow::bail!("代理只支持 http/https,当前是 {other}://"), + }; + let host = url + .host_str() + .filter(|h| !h.is_empty()) + .ok_or_else(|| anyhow::anyhow!("代理地址缺少 host: {raw}"))? + .to_string(); + let port = url + .port_or_known_default() + .ok_or_else(|| anyhow::anyhow!("代理地址缺少端口: {raw}"))?; + let auth = if url.username().is_empty() { + None + } else { + let pass = url.password().unwrap_or(""); + Some(base64_encode(format!("{}:{}", url.username(), pass).as_bytes())) + }; + Ok(Some(Self { tls, host, port, auth })) + } +} + +fn base64_encode(data: &[u8]) -> String { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD.encode(data) +} + +type UpstreamIo = Box; + +trait AsyncReadWrite: AsyncRead + AsyncWrite {} +impl AsyncReadWrite for T {} + +#[derive(Clone)] +struct Upstream { + proxy: Option>, +} + +impl Upstream { + fn new(proxy: Option) -> Self { + Self { proxy: proxy.map(Arc::new) } + } + + async fn connect(&self, dst: SocketAddr, host: Option<&str>) -> anyhow::Result { + match &self.proxy { + None => { + let tcp = tokio::net::TcpStream::connect(dst).await?; + tcp.set_nodelay(true).ok(); + Ok(Box::new(tcp)) + } + Some(proxy) => connect_via_proxy(proxy, dst, host).await, } } - None +} + +async fn connect_via_proxy( + proxy: &ProxyConfig, + dst: SocketAddr, + host: Option<&str>, +) -> anyhow::Result { + let tcp = tokio::net::TcpStream::connect((proxy.host.as_str(), proxy.port)) + .await + .map_err(|e| anyhow::anyhow!("连接代理 {}:{} 失败: {e}", proxy.host, proxy.port))?; + tcp.set_nodelay(true).ok(); + + let mut io: UpstreamIo = if proxy.tls { + let mut cfg = rustls::ClientConfig::builder() + .with_root_certificates(root_store()) + .with_no_client_auth(); + cfg.alpn_protocols = vec![b"http/1.1".to_vec()]; + let connector = tokio_rustls::TlsConnector::from(Arc::new(cfg)); + let name = rustls::pki_types::ServerName::try_from(proxy.host.clone())?; + Box::new( + connector + .connect(name, tcp) + .await + .map_err(|e| anyhow::anyhow!("与代理建立 TLS 失败: {e}"))?, + ) + } else { + Box::new(tcp) + }; + + let authority = match host { + Some(h) if !h.is_empty() => format!("{h}:{}", dst.port()), + _ => dst.to_string(), + }; + let mut req = format!("CONNECT {authority} HTTP/1.1\r\nHost: {authority}\r\n"); + if let Some(auth) = &proxy.auth { + req.push_str(&format!("Proxy-Authorization: Basic {auth}\r\n")); + } + req.push_str("\r\n"); + io.write_all(req.as_bytes()).await?; + io.flush().await?; + + let mut head = Vec::with_capacity(256); + let mut byte = [0u8; 1]; + loop { + let n = io.read(&mut byte).await?; + if n == 0 { + anyhow::bail!("代理在 CONNECT 阶段直接断开"); + } + head.push(byte[0]); + if head.ends_with(b"\r\n\r\n") { + break; + } + if head.len() > 16 * 1024 { + anyhow::bail!("代理 CONNECT 响应头过长"); + } + } + let head_str = String::from_utf8_lossy(&head); + let status_line = head_str.lines().next().unwrap_or("").trim().to_string(); + let ok = status_line + .split_whitespace() + .nth(1) + .map(|code| code == "200") + .unwrap_or(false); + if !ok { + anyhow::bail!("代理 CONNECT 失败: {status_line}"); + } + Ok(io) } pub async fn run_capture_async( @@ -359,6 +508,7 @@ pub async fn run_capture_async( target_hosts: Vec, target_path: Regex, save_path: String, + proxy: Option, cancel_token: CancellationToken, ) -> anyhow::Result> { let device = unsafe { BorrowedAsyncDevice::borrow_raw(tun_fd as RawFd)? }; @@ -371,6 +521,7 @@ pub async fn run_capture_async( let mut bridge_tasks = JoinSet::new(); let tls_acceptor = build_tls_acceptor(); + let upstream = Upstream::new(proxy); let (result_tx, mut result_rx) = tokio::sync::oneshot::channel::(); let result_tx = Arc::new(Mutex::new(Some(result_tx))); @@ -442,11 +593,13 @@ pub async fn run_capture_async( let server_config = tls_acceptor.clone(); let token = cancel_token.clone(); let result_tx = result_tx.clone(); + let upstream = upstream.clone(); tokio::spawn(async move { match handle_tls_connection( app_io, dst, + upstream, hosts, path, save, @@ -460,8 +613,11 @@ pub async fn run_capture_async( }); }else{ let pump_token = cancel_token.clone(); + let upstream = upstream.clone(); bridge_tasks.spawn(async move { - let _ = tcp_passthrough_cancellable(tcp, dst, pump_token).await; + let _ = + tcp_passthrough_cancellable(tcp, dst, None, upstream, pump_token) + .await; }); } } @@ -481,11 +637,16 @@ pub async fn run_capture_async( result } -async fn tcp_passthrough(mut client: IO, dst: SocketAddr) -> anyhow::Result<()> +async fn tcp_passthrough( + mut client: IO, + dst: SocketAddr, + sni: Option<&str>, + upstream: Upstream, +) -> anyhow::Result<()> where IO: AsyncRead + AsyncWrite + Unpin + Send + 'static, { - let mut server = tokio::net::TcpStream::connect(dst).await?; + let mut server = upstream.connect(dst, sni).await?; tokio::io::copy_bidirectional(&mut client, &mut server).await?; Ok(()) } @@ -493,12 +654,14 @@ where async fn tcp_passthrough_cancellable( mut client: IO, dst: SocketAddr, + sni: Option<&str>, + upstream: Upstream, cancel: CancellationToken, ) -> anyhow::Result<()> where IO: AsyncRead + AsyncWrite + Unpin + Send + 'static, { - let mut server = tokio::net::TcpStream::connect(dst).await?; + let mut server = upstream.connect(dst, sni).await?; tokio::select! { _ = cancel.cancelled() => { let _ = tokio::time::timeout(